Mobile Push Notifications (FCM)
Configuration and troubleshooting for server-pushed notifications delivered to
the native app via Firebase Cloud Messaging (FCM) — the out-of-app channel that
wakes a backgrounded/closed app (ADR-058). Distinct from the in-app local
triggers scheduled on-device (mobile/src/notifications/local.*,
plan.ts) — this runbook only covers the server → device leg.
Symptoms
- A device never receives a push (weather/safety, analysis-done, or zone-opening) despite being registered and eligible.
- Registering a device token fails (
POST /users/me/device-tokensreturns non-2xx), or the app silently never registers one. - The
SendPushNotificationMessenger message piles up in thefailedtransport. - A notification arrives but taps into the wrong screen, or a duplicate notification appears while the app is open and already showing the live update via Mercure.
Diagnosis
Server side
-
Is the credential configured?
App\Push\FcmCredentialsreadsFCM_SERVICE_ACCOUNT_JSONlazily — the container boots fine with it empty, but any send attempt raisesRuntimeException("must be configured to send push notifications"), which the worker surfaces as a failed message, not a silent drop:docker compose exec php sh -c 'test -n "$FCM_SERVICE_ACCOUNT_JSON" && echo set || echo EMPTY' docker compose exec worker sh -c 'test -n "$FCM_SERVICE_ACCOUNT_JSON" && echo set || echo EMPTY'(In production, use the
dcalias from README.md.) The check prints no value, on purpose: the variable holds a private key.In dev/recette this is expected to be empty (ADR-058 leaves it unset on purpose so unrelated endpoints keep working); in production an empty value is the bug: check
vault_fcm_service_account_jsonin Ansible Vault (rendered into/etc/bike-trip-planner/app.env) and thatcompose.yaml(not onlycompose.dev.yaml) passes it through to bothphpandworker(project convention, seeCLAUDE.md"New runtime variable?"). -
Check the failed transport for stuck
SendPushNotificationmessages:docker compose exec php bin/console messenger:failed:showA repeated failure with a 401/403 from
oauth2.googleapis.comorfcm.googleapis.compoints at a stale or wrong service-account key (revoked in the Firebase console, or copy-pasted with a missing field —FcmCredentialsvalidatesproject_id/client_email/private_keyare present, so a truncated JSON fails fast with a named field). -
Is the user's token actually registered?
docker compose exec php bin/console dbal:run-sql \ "SELECT id, platform, created_at FROM device_token WHERE \"user_id\" = '<uuid>'"No row: the client never registered (see client-side checks below) or the token was pruned as dead (see Post-action).
-
Is the category opted in?
notification_preferenceholds overrides;zoneOpeningis opt-in/default-OFF — a user who never enabled it will never receive it by design, not by bug.weatherSafetyandanalysisDonedefault ON, so an absent row there means "receiving." -
analysisDonespecifically: it is only sent when no Mercure subscriber is live on/trips/{id}(if the rider is watching, they already getTRIP_READYover SSE — a push would be redundant). If a push is unexpectedly missing right after a computation finishes, check whether the app still held an open SSE subscription (e.g. app briefly backgrounded, not fully closed) — that is the intended no-push path, not a bug. The liveness check itself fails open on a hub error (see mercure-disconnected.md if the hub itself looks unhealthy).
Client side
-
OS permission granted?
fetchDeviceToken()inmobile/src/notifications/push.tsreturnsnull(silent no-op, nothing sent to the backend) whenever permission is denied orgetDevicePushTokenAsync()throws (no Google Play Services, custom ROM, some emulators). Check the device's app notification settings. -
Firebase config bundled into the build.
getDevicePushTokenAsync()needs the app's Android build linked to a Firebase project (normally via agoogle-services.jsonreferenced fromapp.json/app.config.js). No such file is currently committed or wired in this repo — treat it the same way asmobile/credentials.jsonfor release signing: a locally-held, gitignored artifact, not (yet) part of the tracked build config. If device token registration never fires on a real build, this is the first thing to check, not a code regression. -
Registration call itself.
registerDeviceToken()posts to${API_BASE_URL}/users/me/device-tokensand swallows network errors (fire-and-forget from the auth store) — check the backend access log / Caddy logs for the POST rather than expecting a client-side error, and confirmEXPO_PUBLIC_API_URLpoints at a host the device can actually reach (seemobile/README.md, "How the installed app reaches the API"). -
Routing to the wrong screen. Tap-through routing lives in
mobile/src/notifications/push-routing.ts/use-push-routing.ts, keyed off thecategory/datapayload the backend attaches (App\Enum\NotificationCategory) — a mismatch here is a mapping bug in that file, not a delivery problem.
Procedure
-
Configure the credential (once per environment): in the Firebase console, generate a service-account private key (JSON) for the project. In production, compact it to one line (
jq -c . key.json) and set it asvault_fcm_service_account_jsonin Ansible Vault, then re-render and reload the stack (see secrets-rotation.md). For recette/dev testing, exportFCM_SERVICE_ACCOUNT_JSONbefore starting the stack.compose.yamlalready passes it through to bothphpandworker; do not re-invent this incompose.dev.yamlalone. -
Retry a transient failure (e.g. after fixing the credential):
docker compose exec php bin/console messenger:failed:retry --force -
Trigger a push manually to smoke-test end-to-end delivery:
# Weather/safety digest for today's stages docker compose exec php bin/console app:notifications:weather-safety --day=today # Zone-opening announcement (opt-in users only) docker compose exec php bin/console app:notifications:zone-opened <slug> -
Force a re-registration on device (after fixing the client-side cause): log out and back in —
registerDeviceToken()runs on login and is idempotent (upsert by token).
Verification and follow-up
- Confirm
messenger:statsshows theSendPushNotificationqueue draining andfailedempty. - FCM reports a stale token as
UNREGISTERED/NOT_FOUND(HTTP 404);FcmClientcollects those and the handler deletes the row viaDeviceTokenRepository::deleteByTokens()— this is the only pruning path, there is no separate reaper. If a device keeps re-appearing as dead right after a fresh install, suspect it reinstalled with a new token while the old row lingered (expected — the old row prunes itself on the next send attempt, not instantly). - If the root cause was a credential rotation, update Ansible Vault, not just
the running container's env: the next deploy re-reads
/etc/bike-trip-planner/app.env, which the playbook renders from Vault.
References
- ADR-058 — FCM transport, categories, opt-in, dead-token pruning
api/src/Push/FcmClient.php,FcmCredentials.php— send + authmobile/src/notifications/push.ts— device token registration/rotationdocs/runbooks/mercure-disconnected.md— the SSE liveness checkanalysisDonedepends onCLAUDE.md— "New runtime variable? Wire it intocompose.yaml, not just dev/recette."