# Add Browser (Web) Push Notifications alongside existing FCM Mobile Push

> Status: **implemented**. This doc captures the agreed design; the backend changes described below
> have been applied. Real end-to-end browser delivery still depends on the separate React frontend
> wiring up the service worker + VAPID key.

## Context

The CRM currently sends push notifications to the mobile app only, via `PushNotificationService`
(`app/Services/PushNotificationService.php`), a hand-rolled FCM **HTTP v1 API** client (OAuth2
service-account JWT, no `kreait/laravel-firebase` package). Each user has exactly one token, stored
in `user_app_settings.fcm_token` (migration `2026_07_22_000001_create_user_app_settings_table.php`,
model `app/Models/UserAppSetting.php`), registered via
`UserController::update_device_token` (`app/Http/Controllers/UserController.php:433`) and cleared on
logout in `AuthController::logout` (`app/Http/Controllers/AuthController.php:142`).

A web version of the CRM is being built (separate **React** frontend repo) and needs browser push
notifications too. FCM natively supports Web Push (the browser registers a service worker, obtains
an FCM token via the Firebase JS SDK + a VAPID key, and that token is delivered through the FCM
HTTP v1 endpoint — just with a `webpush` payload block instead of `android`/`apns`). So this is
additive to the existing pipeline, not a new notification system.

### Key decision: web push uses a SEPARATE Firebase project

Unlike a single-project setup (where one Firebase project hosts Android, iOS, and Web apps together
and shares one service account), **the web app lives under its own Firebase project, tied to a
different Google account than the Android project.** This is a firm requirement, not a simplification
we can opt out of.

Consequences of two projects:

- Each project has its **own service-account JSON**, its **own `project_id`**, its **own OAuth2
  access token** (obtained from its own service account, cached separately), its **own FCM
  send URL** (`https://fcm.googleapis.com/v1/projects/{project_id}/messages:send`), and its **own
  VAPID key**.
- A `web_fcm_token` is only valid against the **web** project's FCM endpoint; a mobile `fcm_token`
  is only valid against the **mobile** project's endpoint. The platform → credential mapping is
  therefore strict and must never be crossed.
- `PushNotificationService` can no longer resolve a single service account / project / URL once in
  its constructor. Credential resolution must become **per-platform** and happen per send.

### Current mobile credential setup (verified)

- There are **no FCM env vars** set in `.env` / `.env.example`. Mobile runs entirely off config
  defaults in `config/services.php`.
- The mobile service-account JSON lives at `storage/app/private/firebase-service-account.json`
  (the `service_account_path` default). The `project_id` used at runtime is read from **inside that
  JSON** (`$this->serviceAccount['project_id']`); the `config('services.fcm.project_id')` default
  (`agent-chat-22847`) is only a fallback.

So the model going forward: **mobile = existing JSON** at
`storage/app/private/firebase-service-account.json`; **web = a second JSON** (its own Firebase
project / Google account) placed alongside it, e.g.
`storage/app/private/firebase-service-account-web.json`. Mobile behavior stays byte-for-byte
identical; web is fully additive.

### Token storage decision

Keep it simple by adding one new nullable column (`web_fcm_token`) to the existing
`user_app_settings` table, mirroring `fcm_token`, rather than building a normalized multi-token
table. One web token per user (latest browser wins), same as mobile.

This repo stays API-only — the service worker / Firebase JS SDK integration lives in the separate
React web frontend project; this plan only covers what the backend must expose for that React app to
consume (see "Frontend (React) integration notes" below).

A user can be logged into the mobile app and the browser at the same time, so
`PushNotificationService::sendToUser()` must deliver to **both** tokens when both are present, each
through its own Firebase project, creating one `PushNotification` record per platform.

## Changes

### 1. Config — add the web Firebase project + web push settings (`config/services.php`)

Keep the existing `service_account_path` / `project_id` as the **mobile** defaults (unchanged), and
add web equivalents. All env-overridable, with a sensible file default for the web JSON:

```php
'fcm' => [
    'server_key' => env('FCM_SERVER_KEY'),

    // Mobile (existing Firebase project — Android/iOS). Unchanged.
    'service_account_path' => env('FCM_SERVICE_ACCOUNT_PATH', storage_path('app/private/firebase-service-account.json')),
    'project_id' => env('FCM_PROJECT_ID', 'agent-chat-22847'),

    // Web (separate Firebase project — different Google account).
    'web_service_account_path' => env('FCM_WEB_SERVICE_ACCOUNT_PATH', storage_path('app/private/firebase-service-account-web.json')),
    'web_project_id' => env('FCM_WEB_PROJECT_ID'),
    'web_vapid_public_key' => env('FCM_WEB_VAPID_PUBLIC_KEY'),
    'web_notification_icon' => env('FCM_WEB_NOTIFICATION_ICON'),
    'web_default_url' => env('FCM_WEB_DEFAULT_URL'),
],
```

Add matching commented entries to `.env.example`. `web_project_id` is mostly a fallback — the real
project id is read from inside the web JSON, mirroring mobile. No VAPID **private** key is needed
server-side; FCM HTTP v1 auth is the service-account JWT. VAPID is only used client-side by the
browser to obtain a token, and its **public** key is exposed to the frontend (item 8).

### 2. Migration — add `web_fcm_token`

New migration `add_web_fcm_token_to_user_app_settings_table`:

```php
Schema::table('user_app_settings', function (Blueprint $table) {
    $table->text('web_fcm_token')->nullable()->after('fcm_token');
});
```

### 3. Model — `app/Models/UserAppSetting.php`

Add `'web_fcm_token'` to `$fillable`.

### 4. `PushNotificationService` — make credential resolution platform-aware (dual project)

This is the core change driven by the two-project decision. Today `__construct()` resolves
`serviceAccount`, `projectId`, and `fcmUrl` **once**. Replace that with per-platform resolution:

- Add a private resolver, e.g.
  `resolveCredentials(string $platform = 'mobile'): ?array` returning
  `['serviceAccount' => ..., 'projectId' => ..., 'fcmUrl' => ...]`:
  - `mobile` → loads `config('services.fcm.service_account_path')`, project id from the JSON (fallback
    `config('services.fcm.project_id')`).
  - `web` → loads `config('services.fcm.web_service_account_path')`, project id from the web JSON
    (fallback `config('services.fcm.web_project_id')`).
  - `fcmUrl` is built from whichever `project_id` was resolved.
- `getAccessToken()` takes the resolved service account (rather than constructor state). Its cache
  key is already derived from `private_key_id`, so the two projects' OAuth tokens cache separately
  and never collide.
- Keep the existing public method signatures backward compatible; thread `platform` through
  internally, defaulting to `mobile` so mobile behavior is unchanged.

### 5. `PushNotificationService::sendToToken()` — platform-aware payload + endpoint

Add `?string $platform = 'mobile'`. Resolve credentials/URL for that platform (item 4), then:

- When `$platform === 'web'`, build a `webpush` block instead of `android`/`apns`:

  ```php
  'webpush' => [
      'headers' => ['Urgency' => 'high'],
      'notification' => [
          'icon' => config('services.fcm.web_notification_icon'),
      ],
      'fcm_options' => [
          'link' => $data['click_action'] ?? config('services.fcm.web_default_url'),
      ],
  ],
  ```

  and POST to the **web** project's `fcmUrl` with the **web** access token.
- Otherwise keep the existing `android`/`apns` blocks and POST to the **mobile** `fcmUrl` with the
  mobile access token (mobile behavior untouched).

### 6. `PushNotificationService::createNotificationRecord()` — per-platform channel label

Add a `?string $platform = null` param; set `'channel' => $platform ? "fcm_{$platform}" : 'fcm'`
instead of the hardcoded `'fcm'`. Purely additive labeling — `NotificationController.php:257` just
echoes `channel` back, nothing filters on its exact value today. Result: `fcm_mobile` / `fcm_web`.

### 7. `PushNotificationService::sendToUser()` — deliver to all of a user's active tokens

Instead of reading `->fcm_token` only, collect
`['mobile' => fcm_token, 'web' => web_fcm_token]`, filter empties, and loop — for each present
token, create a per-platform `PushNotification` record and call `sendToToken(..., $platform)` so it
goes through the correct project. Delivery status is accurate per channel (e.g. mobile succeeds, web
fails independently). If no tokens exist at all, keep today's single failed-record behavior.

Also give `sendToTokens()` (used for ad-hoc multi-token sends) the same optional `$platform`
passthrough for callers that need it; default stays mobile-shaped.

### 8. Token registration — extend the existing endpoint, don't add a new one

`UserController::update_device_token` gets a new optional `platform` field (`mobile`|`web`, default
`mobile` to preserve current behavior):

```php
$request->validate([
    'device_token' => 'required',
    'platform' => 'nullable|in:mobile,web',
    'current_app_version' => 'nullable|string|max:10',
]);

$tokenField = $request->input('platform', 'mobile') === 'web' ? 'web_fcm_token' : 'fcm_token';

$user->user_app_setting()->updateOrCreate(
    ['user_id' => $user->id],
    [
        $tokenField => $request->device_token,
        'current_app_version' => $request->current_app_version ?? $user->user_app_setting?->current_app_version,
    ]
);
```

Route stays `POST /api/user/device_token/update` — no new route needed.

### 9. Logout — clear only the relevant platform's token

`AuthController::logout` currently unconditionally nulls `fcm_token`. Make it platform-aware the same
way, defaulting to `mobile` so existing mobile-client behavior is unchanged:

```php
$tokenField = $request->input('platform', 'mobile') === 'web' ? 'web_fcm_token' : 'fcm_token';
$user->user_app_setting()->update([$tokenField => null]);
```

### 10. Expose the web project's VAPID public key to the frontend

The VAPID key is **per Firebase project**, so this returns the **web** project's public key. Add a
small unauthenticated endpoint so the separate web frontend doesn't have to hardcode/sync it:
`GET /api/config/push/vapid-key` → `{"success": true, "data": {"vapid_key": "..."}}`, reading
`config('services.fcm.web_vapid_public_key')`. Place it in the public route group in `routes/api.php`
(outside `auth:sanctum`), via a small `ConfigController@pushVapidKey` (or a closure) using the
`ApiResponse` trait. The public VAPID key is not a secret.

### 11. Docs to update alongside implementation

- **`docs/database.md`**: `user_app_settings` isn't currently listed (pre-existing gap) — add a row
  documenting `user_id, fcm_token, web_fcm_token, current_app_version, ...`.
- **`docs/api.md`**: update the `/api/user/device_token/update` row to mention the `platform` param;
  add the new `GET /api/config/push/vapid-key` row; note `platform` on logout.
### Browser notification icon = recipient's account logo (automatic)

`sendToUser()` resolves the recipient's account logo (`Account.logo`, stored on the `public` disk)
to a full URL via `resolveAccountLogoUrl()` and injects it into `$data['web_icon']`. `buildWebpushBlock()`
uses that as the webpush `notification.icon`, falling back to `config('services.fcm.web_notification_icon')`
when the account has no logo. `web_icon` is internal routing metadata and is stripped out of the FCM
`data` payload by `formatDataPayload()` so it never leaks to the client as a data field. Callers can
override branding by passing their own `web_icon` in `$data`. This is multi-tenant safe — each account's
notifications carry that account's own logo.

- **`docs/architecture.md`**: update the `PushNotificationService` row to mention **dual Firebase
  projects** (separate service-account JSON / project / OAuth token / FCM URL per platform), dual
  delivery (mobile `android`/`apns` + browser `webpush`), and per-platform token storage.
- **`docs/deployment.md`**: document the second service-account JSON location
  (`storage/app/private/firebase-service-account-web.json`) and the new web env vars
  (`FCM_WEB_SERVICE_ACCOUNT_PATH`, `FCM_WEB_PROJECT_ID`, `FCM_WEB_VAPID_PUBLIC_KEY`,
  `FCM_WEB_NOTIFICATION_ICON`, `FCM_WEB_DEFAULT_URL`).

## Frontend (React) integration notes (reference only — not part of this repo)

So the two sides line up, here's what the React app will need to do against the endpoints this plan
adds (implemented in the separate frontend repo, not here):

1. `npm install firebase`, initialize the Firebase JS SDK with the **web** Firebase project (the
   separate project / Google account — NOT the mobile project's config).
2. Add a `public/firebase-messaging-sw.js` service worker (standard Firebase Web Push boilerplate —
   `importScripts` the compat SDK, `firebase.initializeApp(...)`, `firebase.messaging()`) and
   register it (`navigator.serviceWorker.register(...)`).
3. On the user opting in: call `Notification.requestPermission()`, then
   `getToken(messaging, { vapidKey })` — fetch `vapidKey` from
   `GET /api/config/push/vapid-key` (item 10) rather than hardcoding it, so backend/frontend stay in
   sync if the key ever rotates.
4. POST the resulting token to `POST /api/user/device_token/update` with `platform: "web"` (item 8) —
   same Sanctum bearer auth as every other authenticated call the React app already makes.
5. Use `onMessage(messaging, callback)` for foreground notifications while the tab is open, and rely
   on the service worker's `onBackgroundMessage` for background/closed-tab delivery — the
   `webpush.fcm_options.link` this backend sets (item 5) is what the browser opens on notification
   click when the tab isn't focused.
6. On logout, call `POST /api/logout` with `platform: "web"` (item 9) so only the browser token is
   cleared, not the mobile one.

## Verification (once implemented)

1. `php artisan migrate` — confirm `web_fcm_token` column added, no errors.
2. Config check — `config('services.fcm.service_account_path')` still points at the mobile JSON and
   `config('services.fcm.web_service_account_path')` points at the web JSON; both resolve.
3. `POST /api/user/device_token/update` with `platform=web` and a fake token — confirm
   `user_app_settings.web_fcm_token` is set and `fcm_token` (mobile) is untouched (and vice versa
   with `platform` omitted).
4. Credential resolution — assert mobile resolution targets the mobile project/URL and web resolution
   targets the web project/URL, and that the two OAuth access tokens cache under different keys.
5. Trigger an existing notification call site (e.g. task assignment via `TaskController`, which uses
   `SendsPushNotifications` → `PushNotificationService::sendToUser`) for a user who has both a
   `fcm_token` and a `web_fcm_token` set (fake tokens are fine here) — with `Http::fake`, confirm two
   `PushNotification` rows are created (`channel` = `fcm_mobile` and `fcm_web`), and that the two
   requests hit the two different project FCM URLs with the right payload shape each
   (`android`/`apns` vs `webpush`).
6. For an end-to-end real check once the frontend has a working service worker + web VAPID key wired
   up: register a real browser token through the endpoint, trigger a notification, and confirm the
   browser shows a system notification with the configured icon and that clicking it opens
   `fcm_options.link`.
7. `POST /api/logout` with `platform=web` — confirm only `web_fcm_token` is cleared, `fcm_token`
   stays intact (and vice versa for `platform=mobile` / omitted).
8. `GET /api/config/push/vapid-key` — confirm it returns the **web** project's public key without
   requiring authentication.
9. Mobile regression — existing mobile-only notification tests stay green; Android delivery path and
   payload are byte-for-byte unchanged.
