feat(notifications): mobile push foundation (APNs + FCM channels) (LC-91) #179

Merged
nrupard merged 1 commit from feat/lc-91-mobile-push-foundation into main 2026-05-22 18:03:14 +02:00
Owner

What

Server-side foundation for native mobile push (iOS APNs, Android FCM), alongside the existing Web Push. Scoped deliberately: the live APNs/FCM HTTP senders, operator-credential storage, and per-channel settings UI are deferred until the native client (LC-99 / LC-123) and Apple/Firebase credentials exist. This PR lands the storage, registration, and dispatch fan-out so that work plugs in without reshaping the notification path. Verified end-to-end with mocks.

Why scoped this way

LC-91 AC #1 needs "the native mobile client can register a device token", but LC-99 (mobile apps) is To-do and LC-123 (Tauri 2) is In-progress: there is no client to register tokens or receive pushes, and no APNs cert / FCM service account / device available here to test live delivery. The mockable trait-based foundation is the verifiable slice; it unblocks the client work and follows the codebase's existing MockPushClient pattern.

Changes

  • Schema (Option B). Auth migration 0022 adds two narrow per-kind tables: apns_subscriptions (device_token + topic) and fcm_subscriptions (registration_token), keyed by the provider-issued token. push_subscriptions (Web Push) is untouched. New db modules mirror it (insert_or_replace, for_user, delete_by_token, bump_last_seen).
  • Dispatch fan-out. push::dispatch now fans out across every configured channel. The gates that decide whether to notify at all (notify_push_enabled, LC-88 DND, LC-90 per-room mute) run once up front so they apply uniformly to all channels; the payload is built once and shared, so the deep-link / title / body are identical across Web Push, APNs, FCM. Each channel spawns under the existing concurrency semaphore, and a dead token (EndpointGone = APNs BadDeviceToken / FCM NOT_REGISTERED) is pruned inline, mirroring the Web Push 410 path.
  • Channel wiring. AppState gains apns_client / fcm_client as Option<Arc<dyn ...>>, both None in production until the live senders land. An unconfigured channel is skipped entirely: no send, and crucially no pruning, so a missing sender never looks like a dead token. ApnsClient / FcmClient traits + MockApnsClient / MockFcmClient mirror the PushClient pattern.
  • Registration. POST /push/apns and POST /push/fcm (authed) store a token for the user; accepted even before a sender is configured so a client can register early.
  • Per-channel disable (AC). A user disables a channel by not registering (or deleting) that kind's token, independent of the others, without touching the global notify_push_enabled.

Acceptance criteria

  • Native client can register a device token; server records it in the right table (registration endpoints + db modules; HTTP-level e2e lands with the client).
  • Notifications fan out to all active subscriptions in parallel (Web Push + APNs + FCM), asserted by the all-channel dispatch test.
  • Dead tokens pruned automatically (410 / BadDeviceToken / NOT_REGISTERED), tested per channel.
  • Consistent payload across channels (built once, shared; asserted equal in the fan-out test).
  • Per-room mute (LC-90), DND (LC-88), per-message kind apply uniformly across channels (shared up-front gates; suppression tests for notify-disabled / muted-all / DND).
  • Per-channel disable without losing the per-message preference (per-token registration, independent of notify_push_enabled).

Deferred (live-wiring phase: needs client + credentials)

  • Real APNs (token-based JWT) and FCM (HTTP v1) network senders.
  • Encrypted provider-credential storage in settings.db (AES-256-GCM, like SMTP).
  • Settings UI for per-channel toggles.

Test

just check, just test, just test-saas green. New: db round-trip tests for both tables; push_dispatch all-channel fan-out, APNs/FCM dead-token pruning, uniform suppression (notify-disabled / muted-all / DND), and a no-op-when-unconfigured guard. Migration 0022 added to every test pool helper. The routes_uploads upload-pipeline flake under concurrent-binary load is pre-existing (CLAUDE.md) and passes in isolation.

🤖 Generated with Claude Code

## What Server-side foundation for native mobile push (iOS APNs, Android FCM), alongside the existing Web Push. Scoped deliberately: the live APNs/FCM HTTP senders, operator-credential storage, and per-channel settings UI are deferred until the native client (LC-99 / LC-123) and Apple/Firebase credentials exist. This PR lands the storage, registration, and dispatch fan-out so that work plugs in without reshaping the notification path. Verified end-to-end with mocks. ## Why scoped this way LC-91 AC #1 needs "the native mobile client can register a device token", but LC-99 (mobile apps) is To-do and LC-123 (Tauri 2) is In-progress: there is no client to register tokens or receive pushes, and no APNs cert / FCM service account / device available here to test live delivery. The mockable trait-based foundation is the verifiable slice; it unblocks the client work and follows the codebase's existing `MockPushClient` pattern. ## Changes - **Schema (Option B).** Auth migration `0022` adds two narrow per-kind tables: `apns_subscriptions` (`device_token` + `topic`) and `fcm_subscriptions` (`registration_token`), keyed by the provider-issued token. `push_subscriptions` (Web Push) is untouched. New db modules mirror it (`insert_or_replace`, `for_user`, `delete_by_token`, `bump_last_seen`). - **Dispatch fan-out.** `push::dispatch` now fans out across every configured channel. The gates that decide whether to notify at all (`notify_push_enabled`, LC-88 DND, LC-90 per-room mute) run once up front so they apply uniformly to all channels; the payload is built once and shared, so the deep-link / title / body are identical across Web Push, APNs, FCM. Each channel spawns under the existing concurrency semaphore, and a dead token (`EndpointGone` = APNs `BadDeviceToken` / FCM `NOT_REGISTERED`) is pruned inline, mirroring the Web Push 410 path. - **Channel wiring.** `AppState` gains `apns_client` / `fcm_client` as `Option<Arc<dyn ...>>`, both `None` in production until the live senders land. An unconfigured channel is skipped entirely: no send, and crucially no pruning, so a missing sender never looks like a dead token. `ApnsClient` / `FcmClient` traits + `MockApnsClient` / `MockFcmClient` mirror the `PushClient` pattern. - **Registration.** `POST /push/apns` and `POST /push/fcm` (authed) store a token for the user; accepted even before a sender is configured so a client can register early. - **Per-channel disable (AC).** A user disables a channel by not registering (or deleting) that kind's token, independent of the others, without touching the global `notify_push_enabled`. ## Acceptance criteria - [x] Native client can register a device token; server records it in the right table (registration endpoints + db modules; HTTP-level e2e lands with the client). - [x] Notifications fan out to all active subscriptions in parallel (Web Push + APNs + FCM), asserted by the all-channel dispatch test. - [x] Dead tokens pruned automatically (410 / BadDeviceToken / NOT_REGISTERED), tested per channel. - [x] Consistent payload across channels (built once, shared; asserted equal in the fan-out test). - [x] Per-room mute (LC-90), DND (LC-88), per-message kind apply uniformly across channels (shared up-front gates; suppression tests for notify-disabled / muted-all / DND). - [x] Per-channel disable without losing the per-message preference (per-token registration, independent of `notify_push_enabled`). ## Deferred (live-wiring phase: needs client + credentials) - Real APNs (token-based JWT) and FCM (HTTP v1) network senders. - Encrypted provider-credential storage in `settings.db` (AES-256-GCM, like SMTP). - Settings UI for per-channel toggles. ## Test `just check`, `just test`, `just test-saas` green. New: db round-trip tests for both tables; `push_dispatch` all-channel fan-out, APNs/FCM dead-token pruning, uniform suppression (notify-disabled / muted-all / DND), and a no-op-when-unconfigured guard. Migration `0022` added to every test pool helper. The `routes_uploads` upload-pipeline flake under concurrent-binary load is pre-existing (CLAUDE.md) and passes in isolation. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(notifications): mobile push foundation (APNs + FCM channels) (LC-91)
All checks were successful
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 7s
check-secrets / Kingfisher (push) Successful in 10s
check-secrets / Nosey parker (pull_request) Successful in 3s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 1m22s
Create release / Create release from merged PR (pull_request) Has been skipped
477370a820
Server-side foundation for native mobile push, alongside the existing Web Push. Live APNs/FCM HTTP senders and operator-credential storage are deferred until the native client (LC-99 / LC-123) and Apple/Firebase credentials exist; this lands the storage, registration, and dispatch fan-out so that work plugs in without reshaping the notification path.

Schema (Option B from the ticket): auth migration 0022 adds two narrow per-kind tables, apns_subscriptions (device_token + topic) and fcm_subscriptions (registration_token), keyed by the provider-issued token. The Web Push push_subscriptions table is untouched. New db modules mirror push_subscriptions (insert_or_replace upsert, for_user, delete_by_token, bump_last_seen).

Dispatch: push::dispatch now fans out across all configured channels. The gates that decide whether to notify at all (notify_push_enabled, LC-88 DND, LC-90 per-room mute) run once, up front, so they apply uniformly to every channel; the payload is built once and shared, keeping the deep-link / title / body identical across Web Push, APNs, and FCM. Each channel fans out in its own spawned tasks under the existing concurrency semaphore, and a dead token (EndpointGone, i.e. APNs BadDeviceToken / FCM NOT_REGISTERED) is pruned inline, mirroring the Web Push 410 path.

Channel wiring: AppState gains apns_client and fcm_client as Option<Arc<dyn ...>>, both None in production until the live senders land. An unconfigured channel is skipped entirely (no send, and crucially no token pruning, so a missing sender never looks like a dead token). The ApnsClient / FcmClient traits + MockApnsClient / MockFcmClient mirror the PushClient pattern so the fan-out is fully exercised in tests.

Registration: POST /push/apns and POST /push/fcm (authed) store a device/registration token for the user. Tokens are accepted even before a sender is configured, so a native client can register early and delivery starts the moment credentials are wired.

Per-channel disable (AC): a user disables a channel by not registering (or deleting) that kind's token, independent of the others, without touching the global notify_push_enabled preference.

Tests: db round-trip tests for both new tables; push_dispatch gains all-channel fan-out (asserting one delivery per channel with an identical payload), APNs/FCM dead-token pruning, and uniform suppression under notify-disabled / room-muted-all / DND, plus a no-op-when-unconfigured guard. Migration 0022 added to every test pool helper. just check / just test / just test-saas green (the routes_uploads upload-pipeline flake under concurrent-binary load is pre-existing and passes in isolation).

Deferred to the live-wiring phase (needs client + credentials): real APNs token-based JWT and FCM HTTP v1 senders, encrypted provider-credential storage in settings.db, and the settings UI for per-channel toggles.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-91-mobile-push-foundation 2026-05-22 18:03:14 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!179
No description provided.