# Plan: Push Notifications Revamp (Firebase FCM)

## TL;DR
Migrated push notification infrastructure from the **dead legacy FCM API** to FCM v1 HTTP API with OAuth2 service account auth. Upgraded Firebase client SDK to v10.14.1 compat, rewrote service worker with background/click handlers, upgraded device entity with lifecycle fields, and fixed subscription endpoint. **Added iOS PWA support** — complete manifest.json, apple meta tags, iOS "Add to Home Screen" prompt, Android PWA install prompt, offline fallback page, and service worker with fetch/cache handlers. Push notifications now work on iOS (via PWA), Android, and desktop. Next: targeted notifications, token cleanup, and notification preferences.

---

## Implementation Status

| Step | Status | Deployed |
|------|--------|----------|
| Step 1: FCM v1 Backend Migration | ✅ Complete | 2026-03-04 |
| Step 2: Device Token Entity Upgrade | ✅ Complete | 2026-03-04 |
| Step 3: Fix Subscription Endpoint | ✅ Complete | 2026-03-04 |
| Step 4: Subscribe Prompt (bilingual) | ✅ Complete (prior session) | 2026-03-03 |
| Step 5: Firebase SDK v10 Upgrade | ✅ Complete | 2026-03-04 |
| Step 6: Service Worker Rewrite | ✅ Complete | 2026-03-04 |
| Step 6b: iOS + Android PWA Support | ✅ Complete | 2026-03-05 |
| Step 7: Targeted Notifications | ❌ Not started | — |
| Step 8: Token Lifecycle Management | ❌ Not started | — |
| Step 9: Notification Preferences | ❌ Not started | — |
| Step 10: Web Notification Integration | ❌ Not started | — |
| Step 11: Translations | ❌ Not started | — |

---

## Current State (Post-Migration)

| Component | File | Status |
|-----------|------|--------|
| Firebase project | `shamraacademia` (790602994820) | Active |
| Firebase config (client) | `notificationmain.js` (v5.6) | **Firebase v10.14.1 compat** |
| Service worker | `public/firebase-messaging-sw.js` | **Root path, onBackgroundMessage + notificationclick handlers** |
| VAPID key | `BNSEHUVOkhcMBJaumcEq...` in `notificationmain.js` | Working |
| Device token storage | `academy_push_notification_device` table | **Upgraded: platform, createdAt, lastUsedAt, isActive, userAgent** |
| Subscribe prompt UI | `templates/base.html.twig` | **Bilingual, mobile-friendly, 15s+scroll delay, "Don't ask again"** |
| Server-side sender | `src/Service/FirebasePushService.php` | **FCM v1 API, OAuth2 service account** |
| Service account | `config/firebase/service-account.json` (gitignored) | On prod: `/var/www/html/academia_v2/config/firebase/service-account.json` |
| Subscription endpoint | `POST /push-subscribe` + legacy `/push-subscripe` | **Fixed spelling, token dedup, platform detection** |
| Send trigger | `ResearchController::newAction()` | Active — uses `FirebasePushService`, deactivates UNREGISTERED tokens |
| Token cleanup | — | **Not yet implemented** |
| Targeting | — | **Not yet implemented** (broadcasts to all) |
| Notification preferences | — | **Not yet implemented** |

---

## What Was Implemented

### Step 1: Create Firebase Service Account & Migrate to FCM v1 ✅

**Commits**: `1c3564e8`, `da8ec992`, `a57ffb37`, `81a60503`, `ce0995f4`, `ab7e4e8f`, `f8f99626`, `6be32b7e`

**Created `src/Service/FirebasePushService.php`:**
- OAuth2 token via `Google\Auth\Credentials\ServiceAccountCredentials`
- FCM v1 endpoint: `https://fcm.googleapis.com/v1/projects/shamraacademia/messages:send`
- Methods: `sendToDevice()`, `sendToMultiple()`, `sendToTopic()`, `subscribeToTopic()`
- Token caching with 60s expiry buffer
- `sendToMultiple()` loops individually, returns `['sent' => int, 'failed' => int, 'unregistered' => string[]]`
- Sends both `notification` + `data` + `webpush.notification` + `webpush.fcm_options.link`

**Service account JSON:**
- Stored at `config/firebase/service-account.json` (gitignored)
- On prod: owned by `www-data`, permissions `600`
- Registered in `config/services.yaml` with `$firebaseCredentialsPath` argument

**Removed legacy:**
- `firebase_notification_key=AAAAuBOcVIQ:...` replaced with `FIREBASE_CREDENTIALS_PATH=config/firebase/service-account.json` in `.env`

### Step 2: Upgrade Device Token Entity ✅

**Updated `src/syndex/AcademicBundle/Entity/PushNotificationDevice.php`:**
- Added: `platform` (string 32), `createdAt` (datetime), `lastUsedAt` (datetime), `isActive` (boolean, default true), `userAgent` (string 512)
- Changed: `deviceId` from VARCHAR(255) to TEXT
- Added `#[ORM\PrePersist]` lifecycle callback for auto-setting timestamps

**Migration `Version20260304100000`:**
- ALTER TABLE for new columns
- Backfilled existing 771 rows: `created_at=NOW()`, `is_active=1`
- Executed on prod successfully

### Step 3: Fix Subscription Endpoint ✅

**Rewritten `PushNotificationsController`:**
- `pushSubscribeAction()` at `POST /push-subscribe`: handles new + existing tokens, updates `lastUsedAt`/`userAgent`/`platform`, reassigns tokens across users
- `pushSubscripeAction()` at `/push-subscripe`: legacy alias delegates to `pushSubscribeAction()`
- `detectPlatform()`: parses User-Agent for android/ios/windows/macos/linux/chromeos/web

**Routing (`src/syndex/AcademicBundle/Resources/config/routing.yml`):**
- New: `shamra_academy_push_subscribe` → `POST /push-subscribe`
- Legacy: `shamra_academy_push_subscripe` → `/push-subscripe` (backward compat)

### Step 4: Subscribe Prompt ✅ (prior session)

Already implemented in a prior session:
- Bilingual (Arabic/English based on locale)
- Mobile-friendly (removed `@media(max-width:800px) { display:none }`)
- 15-second + scroll delay
- "Don't ask again" checkbox with 30-day localStorage expiry
- Not shown if: already granted, denied by browser, or dismissed

### Step 5: Upgrade Firebase SDK to v10 ✅

**In `templates/base.html.twig`:**
```html
<script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js"></script>
<script src="{{ asset('bundles/syndexacademic/js/notificationmain.js?v5.6') }}"></script>
```

**Key v10 compat issues resolved:**
- `messaging.requestPermission()` removed → use native `Notification.requestPermission()`
- `firebase.messaging()` must NOT be called before SW is active → deferred to inside `registerToken()` after `navigator.serviceWorker.ready`
- `deleteToken()` requires active SW with `pushManager` → removed entirely (scope change auto-refreshes tokens)

### Step 6: Improve Service Worker ✅

**Created `public/firebase-messaging-sw.js` (root path):**
- Firebase SDK looks for SW at `/firebase-messaging-sw.js` by default
- `onBackgroundMessage` handler: extracts from both `notification` and `data` payloads
- `notificationclick` handler: focuses existing shamra tab or opens new window
- Icon: `/img/shamra-academia-logo.png`

**Updated `notificationmain.js` (v5.6):**
- Registers SW from `/firebase-messaging-sw.js` (root)
- `firebase.messaging()` created lazily only after `navigator.serviceWorker.ready` resolves
- `onMessage` foreground handler set up inside `registerToken()` after SW is active
- Posts token to `/push-subscribe`

**Updated `ResearchController`:**
- Only sends to `isActive=true` tokens
- Truncates abstract to 200 chars in push body
- Deactivates UNREGISTERED tokens after send

---

## Remaining Steps

### Step 7: Add Targeted Notifications

**7a. FCM Topics (field-based):**

When a user subscribes, also subscribe their token to a topic based on their `studyField`:
```php
// Client-side: after getToken()
fetch('/api/push/subscribe', { token, topics: ['field_12', 'community_5'] });

// Server-side: subscribe token to FCM topic
$this->firebasePush->subscribeToTopic($token, 'field_' . $user->getStudyField()->getId());
```

FCM topics allow server-to-topic pushes without fetching all tokens:
```php
$this->firebasePush->sendToTopic('field_12', 'New paper in Computer Science', '...', '/show/xyz');
```

**7b. User-targeted notifications:**

For personal events (answer to question, follower activity), send to specific user's tokens:
```php
$tokens = $deviceRepo->findActiveByUser($targetUser);
$this->firebasePush->sendToMultiple($tokens, $title, $body, $url);
```

**7c. Notification types to implement:**

| Trigger | Target | Priority | Message |
|---------|--------|----------|---------|
| New paper in field | Topic `field_{id}` | Normal | "بحث جديد في {field}: {title}" |
| Answer to your question | User tokens | High | "أجاب {name} على سؤالك" |
| Answer upvoted | User tokens | Normal | "تم التصويت على إجابتك" |
| New community question | Topic `community_{id}` | Normal | "سؤال جديد في {community}" |
| Followed researcher activity | User tokens | Normal | "{name} نشر بحثاً جديداً" |
| Weekly digest reminder | All active tokens | Normal | "لديك {count} أبحاث جديدة هذا الأسبوع" |
| New comment on your post | User tokens | High | "{name} علّق على منشورك" |
| Reply to your comment | User tokens | High | "{name} ردّ على تعليقك" |
| New follower | User tokens | Normal | "{name} بدأ بمتابعتك" |
| Shared paper activity | User tokens | Normal | "{name} شارك بحثك" |

---

### Social Layer Notifications — Current State (March 5, 2026)

The Social Layer project (see `futures/06-follow-researchers.md`) has implemented Phases 1-4 and already sends the following notifications:

#### In-App Web Notifications (Bell Icon)

All of these use `UserNotification::notifyUser()` → persists `UserWebNotification` → visible in the notification bell dropdown.

| Event | Controller | Message (AR/EN) |
|-------|-----------|----------------|
| New follower | `FollowController::follow()` | "{name} بدأ بمتابعتك" / "{name} started following you" |
| Comment on post | `ProfilePostCommentController::create()` | "{name} علّق على منشورك" / "{name} commented on your post" |
| Reply to comment | `ProfilePostCommentController::reply()` | "{name} ردّ على تعليقك" / "{name} replied to your comment" |
| Post liked | `ProfilePostController::toggleLike()` | "{name} أعجب بمنشورك" |

#### Email Notifications (Listmonk Transactional)

Comment/reply events also trigger an email via `ListmonkService::sendCommentNotificationEmail()`:

| Event | Template ID | Channel | Locale-Aware |
|-------|------------|---------|-------------|
| Comment on post | 8 | Listmonk TX API | Yes — switches AR/EN based on `user.preferredLocale` |
| Reply to comment | 8 | Listmonk TX API | Yes — same template, different `type` param |

**Key files:**
- `src/Service/ListmonkService.php` — `sendCommentNotificationEmail()` method
- `src/Controller/ProfilePostCommentController.php` — calls email in `create()` and `reply()`
- Listmonk template ID 8: "Shamra Comment Notification TX" — bilingual Go template

#### Push Notifications (Not Yet Wired)

Push notifications for social events are **not yet implemented**. When Step 7 (Targeted Notifications) is built, the social layer events above should be wired to `FirebasePushService::sendToMultiple()` for the target user's active device tokens. This would create a **triple-channel** notification system:
1. **Web bell** (on-site) — already working
2. **Email** (off-site, async) — already working for comments/replies
3. **Push** (off-site, real-time) — pending Step 7

### Step 8: Token Lifecycle Management

**8a. Cleanup stale tokens:**

Create `src/Command/CleanupPushTokensCommand.php` — `app:cleanup-push-tokens`:
- Query all active tokens
- Send a dry "data-only" message to each (no visible notification)
- FCM returns `UNREGISTERED` or `INVALID_ARGUMENT` for stale tokens
- Mark those as `isActive = false`
- Run weekly via cron

**8b. Handle FCM errors inline:**

In `FirebasePushService::sendToDevice()`, check response for:
- `UNREGISTERED` → mark token inactive in DB
- `INVALID_ARGUMENT` → mark token inactive
- `QUOTA_EXCEEDED` → retry with exponential backoff
- `UNAVAILABLE` → retry once

**8c. Token refresh:**

In `notificationmain.js`, listen for token refresh:
```js
messaging.onTokenRefresh(() => {
    messaging.getToken().then(newToken => {
        fetch('/api/push/subscribe', { method: 'POST', body: { token: newToken } });
    });
});
```

### Step 9: Notification Preferences (User Settings)

Add to `src/Entity/User.php`:
- `pushNotificationsEnabled` (boolean, default true)
- `pushNotificationTypes` (json, default `["new_paper","answer","community"]`) — which types to receive

Add to profile settings (`templates/Profile/show/personal_info.html.twig`):
- Toggle: "Enable push notifications" (on/off)
- Checkboxes: "Notify me about: ☑ New papers ☑ Answers to my questions ☑ Community activity ☐ Weekly digest"

### Step 10: Integrate with Web Notifications (Plan 02)

When `UserNotification::notifyUser()` is called (Plan 02), also send a push notification if:
- User has `pushNotificationsEnabled = true`
- User has active device tokens
- The notification type is in user's `pushNotificationTypes`

This creates a dual-channel: web notification bell (when on-site) + push notification (when off-site).

### Step 11: Translations

Add push notification messages to:
- `translations/Notifications.ar.yml` — prompt text, notification messages, settings labels
- `translations/Notifications.en.yml`

---

## Migration Log

| Date | Commit | Action |
|------|--------|--------|
| 2026-03-04 | `1c3564e8` | FCM v1 backend: `FirebasePushService.php`, service account, services.yaml |
| 2026-03-04 | `da8ec992` | Entity upgrade, migration `Version20260304100000`, controller rewrite, SDK v10 |
| 2026-03-04 | `a57ffb37` | v5.1: deleteToken + localStorage flag for stale token refresh |
| 2026-03-04 | `81a60503` | v5.2: Fix `requestPermission` for Firebase v10 compat |
| 2026-03-04 | `ce0995f4` | v5.3: Fix SW 404 — created `public/firebase-messaging-sw.js` at root |
| 2026-03-04 | `ab7e4e8f` | v5.4: Remove `deleteToken()` (pushManager required active SW) |
| 2026-03-04 | `f8f99626` | v5.5: Defer `firebase.messaging()` inside `registerToken()` |
| 2026-03-04 | `6be32b7e` | v5.6: Chain through `navigator.serviceWorker.ready` before messaging init |

---

## Verification (Completed)

1. ✅ **FCM v1 works**: Sent test push to user "shadi" device token 776 → HTTP 200 → notification displayed
2. ✅ **Subscribe prompt**: Bilingual, mobile-friendly, 15s+scroll delay, "Don't ask again" checkbox with 30-day localStorage
3. ✅ **Service worker**: Background notification + click handler verified working
4. ✅ **Token lifecycle**: New tokens get `platform`, `createdAt`, `lastUsedAt`, `userAgent`, `isActive`
5. ✅ **UNREGISTERED handling**: `ResearchController` deactivates stale tokens after send

## Verification (Remaining)

6. ❌ **Topic subscription**: Not yet implemented
7. ❌ **Targeted push**: Not yet implemented
8. ❌ **Token cleanup command**: Not yet implemented
9. ❌ **Notification preferences**: Not yet implemented

---

## Decisions

- **FCM v1 over alternatives (OneSignal, Pusher)**: Already have Firebase project + tokens in DB. Migration is cheaper than switching providers.
- **FCM Topics over individual sends**: For field-based notifications, topics are far more efficient (1 API call vs N calls). Individual sends only for personal events.
- **Web push first, mobile app later**: `platform` field on device entity future-proofs for a React Native / Flutter app with FCM integration.
- **15-second + scroll delay**: Industry best practice. Prompting immediately on page load has <5% accept rate. After engagement: 15-25% accept rate.
- **Dual-channel (bell + push)**: Push for off-site, bell for on-site. Never duplicate — if user is on the site, prefer the bell (check SW `clients.matchAll()` before showing push).
