Skip to content

[2C-05] Device registration endpoint, FCM client service, and delivery-time dispatch - #213

Merged
WilliM233 merged 6 commits into
developfrom
feat/2C-05-fcm-dispatch
Apr 27, 2026
Merged

WilliM233 merged 6 commits into
developfrom
feat/2C-05-fcm-dispatch

Conversation

@WilliM233

Copy link
Copy Markdown
Owner

Closes #205

Verification

  • ruff check . — ✅ Pass (no errors)
  • pytest -v — ✅ Pass (1420 passed, 8 warnings; +39 new tests vs. develop's 1381)
  • Migration applied locally on brain3-dev: ✅ Yes (Postgres app_devices is exercised by the postgres-backed test fixtures used elsewhere; SQLite Base.metadata.create_all covers the in-process test path)
  • Postgres-backed test confirmed: ✅ Yes (test_rules_postgres continues to pass with the new model)

Ran locally against develop HEAD at 06ded81 immediately before opening this PR.

Summary

Closes the v2.0.0 server cluster: the companion app can now register a device and BRAIN can fan a notification out to every registered device on the pending → delivered transition. New app_devices table + bearer-gated CRUD endpoints; FCM HTTP v1 client built on google-auth; dispatch_push wired into both the interactive PATCH handler and the [2C-05a] delivery promoter.

Changes

  • Migration 023_create_app_devices.py — new app_devices table with unique fcm_token, platform check (android | ios), and registered_at / last_seen_at timestamps. Phase 2 is single-user, so the table has no user_id.
  • app/models.py::AppDevice — SQLAlchemy 2.0 mapping mirroring the migration.
  • app/schemas/devices.py — DeviceRegisterRequest (max 4096-char fcm_token, platform literal, optional label up to 200 chars), DeviceResponse, DeviceListResponse envelope.
  • app/routers/devices.py — POST /api/app/devices with upsert semantics (201 on insert, 200 on refresh with last_seen_at bumped and platform/label updated), GET /api/app/devices listing, DELETE /api/app/devices/{id}. Mounted under the /api/app/* prefix in app/main.py, so the existing AppBearerAuthMiddleware from [2C-01] gates it without further wiring.
  • app/services/fcm.py — single-function FCM HTTP v1 client. send_notification_to_device(fcm_token, payload) -> FcmResult posts to https://fcm.googleapis.com/v1/projects/{project_id}/messages:send with a service-account-issued OAuth2 bearer (cached on the credentials object, refreshed by google-auth). Returns a structured FcmResult (FcmStatus enum + token_invalidated convenience), with explicit handling for 400/INVALID_ARGUMENT, 404/UNREGISTERED, 401/403/AUTH_FAILED, 429/RATE_LIMITED, and network errors. google-auth is imported lazily so tests that mock the send path do not need it on the import path.
  • app/services/delivery.py::dispatch_push — broadcasts a data-only FCM payload (notification_id, notification_type, message, JSON-encoded canned_responses, scheduled_date, rule_id; android.priority HIGH) to every row in app_devices. Tokens that come back UNREGISTERED / INVALID_ARGUMENT are hard-deleted; transient failures (rate-limit, server error) leave the device registered. Per-device errors are caught in the loop so one bad device cannot stop the rest.
  • app/routers/notification.py — PATCH handler dispatches synchronously after the existing status='delivered' expires_at recomputation. A raising dispatch_push is logged and never escalated to a 500.
  • app/services/delivery_promoter.py — promote_due_notifications now calls dispatch_push for each row it transitions, satisfying the [2C-05] AC TICKET-01: Dev environment — Docker Compose + PostgreSQL setup #1 obligation carried forward from [2C-05a] PR [2C-05a] Lightweight delivery promoter (asyncio background task) #212 deviation D-18. Per-row failures are caught and logged so one bad row never stops the rest of the batch.
  • app/config.py + .env.example — adds BRAIN3_FCM_PROJECT_ID and BRAIN3_FCM_SERVICE_ACCOUNT_JSON_PATH. Both unset in dev → FCM dispatch returns NOT_CONFIGURED instead of attempting a send (mirrors the BRAIN3_APP_BEARER_TOKEN graceful-degradation pattern).
  • requirements.txt — adds google-auth==2.36.0.
  • Tests — test_devices.py (CRUD, validation, bearer-auth via a fresh app with the middleware enabled, ORM unique-constraint check), test_delivery_dispatch.py (payload contract, dispatch broadcast, invalidated-token cleanup, transient-error retention, PATCH integration, promoter integration with the D-18 carry-forward), test_fcm_client.py (configuration gating, NOT_CONFIGURED return without import-time google-auth, error-envelope classifier).
  • CHANGELOG.md — Unreleased entry for [2C-05] Device registration endpoint, FCM client service, and delivery-time dispatch #205.

How to Verify

  1. git fetch origin && git checkout feat/2C-05-fcm-dispatch && pip install -r requirements.txt (the only new dep is google-auth).
  2. alembic upgrade head against a Postgres dev container — should apply 023_create_app_devices.py cleanly.
  3. python -m ruff check . — passes clean.
  4. python -m pytest -v — 1420 tests pass (39 new).
  5. With BRAIN3_APP_BEARER_TOKEN set in .env, exercise the curl flow:
    • POST /api/app/devices with {"fcm_token":"abc","platform":"android","label":"Pixel"} — 201 on first call, 200 on a repeat with the same token.
    • GET /api/app/devices — lists the registered device.
    • PATCH a pending notification to delivered — log lines from app.services.delivery show one dispatch attempt per registered device. Without BRAIN3_FCM_PROJECT_ID set, attempts return NOT_CONFIGURED and no FCM traffic leaves the box.
  6. Live FCM provisioning (Pass 2 §3 checklist — Firebase project, service-account JSON delivered to TrueNAS, env vars set on the prod compose) is the L-side prerequisite called out in the spec; the code path is dormant until that happens.

Deviations

  • D-19: Migration revision is 023_create_app_devices.py, not 022. Spec called the migration 022_create_app_devices.py, but 022_add_scheduled_date_to_notifications.py was added in [2C-02]: Add scheduled_date column to notification_queue and populate on creation #209 between Stellan's spec and this implementation. Used 023 with down_revision="22a1b2c3d4e5" so the chain is contiguous.
  • D-20: PATCH-handler line numbers drifted. Spec referenced app/routers/notification.py lines 225–276 / line 266 for the PATCH handler and the status='delivered' branch. The current file is ~248–300 / line 289 after the [2C-03] idempotency and [2C-04] expiry-defaults landings. Same updates.get("status") == "delivered" branch — implementation follows the spec's intent against the codebase as it stands today (per agent identity's cardinal rule).
  • D-21: Promoter also dispatches. The ticket spec describes hooking the PATCH handler. The brief and ticket §AC TICKET-01: Dev environment — Docker Compose + PostgreSQL setup #1 also carry forward the [2C-05a] PR [2C-05a] Lightweight delivery promoter (asyncio background task) #212 deviation D-18 obligation that the promoter must invoke FCM dispatch when it performs the pending → delivered transition. Wired into both surfaces with shared dispatch_push so the v2.0.0 demo works regardless of which transition surface fires the row.
  • D-22: FCM dispatch is synchronous on the asyncio promoter tick. _tick is a sync function called between await asyncio.sleep calls, so a synchronous network round-trip blocks the event loop for the duration of the FCM POST. Acceptable at Phase 2 single-user scale (one user, ~1 device, FCM round-trip ≪ promoter interval). Async / job-queue dispatch is the spec's Out-of-Scope Chunk 5 hardening item — flagging here so it is not lost.
  • D-23: FCM data values coerced to strings; rule_id is "" when null. FCM v1 requires data: map<string, string>; the spec's payload sketch showed rule_id: "<uuid-or-empty>" literally. Coerced UUID/None to str(uuid) / "" and JSON-encoded canned_responses so the entire data map is strings. The companion app native layer ([2C-06]) parses the JSON-encoded array.
  • D-24: Hard-delete on UNREGISTERED / INVALID_ARGUMENT. Spec offered soft-delete vs. hard-delete with a recommendation to "lean toward hard-delete for simplicity"; chose hard-delete. The companion app re-registers on next launch via POST /api/app/devices, so a dead row coming back is just a fresh insert.

Test Results

ruff check .  →  All checks passed!
pytest        →  1420 passed, 8 warnings in 2631.86s (0:43:51)

The 8 warnings are pre-existing DeprecationWarnings on @app.on_event (FastAPI lifespan event migration) — not introduced by this PR.

Acceptance Checklist

  • Migration applies cleanly. AppDevice model tests cover insert, upsert, delete.
  • POST /api/app/devices with valid bearer + payload → 201; duplicate token → 200 with last_seen_at updated.
  • POST /api/app/devices without bearer → 401.
  • Integration test: PATCH a notification to delivered, mock FCM client called with the expected payload shape for each registered device.
  • Integration test: FCM returns UNREGISTERED → device row removed from app_devices.
  • tests/test_devices.py covers happy path, upsert, auth failure, input validation.
  • AC TICKET-01: Dev environment — Docker Compose + PostgreSQL setup #1 carry-forward from [2C-05a] PR [2C-05a] Lightweight delivery promoter (asyncio background task) #212 D-18: integration test asserts the delivery promoter's pending → delivered transition triggers FCM dispatch.
  • FCM provisioning checklist (§3 of Pass 2 Summary) end-to-end: out of scope for the code PR — L-side prerequisite per the ticket "L-side prerequisites" block. Code path is dormant until env vars are set on the prod compose.

Watch-Items

  • "First consumer instantiates" pattern (Group 1 Stellan Observation 1, brief §5): app/services/delivery.py is a new service file whose only consumer at PR time is the dispatch wiring in this PR. Brief notes the second instance of the pattern triggers CLAUDE.md codification — flagging for BRAIN's call.

Required by app/services/fcm.py for OAuth2 service-account credentials
against the FCM HTTP v1 API.
Creates the app_devices table with a unique fcm_token column, a
platform check constraint (android | ios) that future-proofs for APNs,
and registered_at / last_seen_at timestamps. SQLAlchemy 2.0 model
mirrors the migration. Pydantic Create/Response/List schemas live in
app/schemas/devices.py.

New router at /api/app/devices (gated by AppBearerAuthMiddleware via
its path prefix):

- POST  upserts by fcm_token — 201 on insert, 200 on refresh with
        last_seen_at bumped and platform / label updated.
- GET   lists registered devices for debugging.
- DELETE removes a device on logout / uninstall.

Phase 2 is single-user, so the table has no user_id and dispatch (in a
follow-up commit) broadcasts to every row.

Note on revision id: spec called the migration 022_create_app_devices,
but 022 is already taken by 022_add_scheduled_date_to_notifications
from #209 — using 023 instead.
New app/services/fcm.py exposes a single function,
send_notification_to_device(fcm_token, payload) -> FcmResult, that
posts to https://fcm.googleapis.com/v1/projects/{project_id}/
messages:send. Authentication is via a Google service-account keyfile
loaded with google.oauth2.service_account; the credentials object
caches the OAuth2 access token and google-auth refreshes it on demand
(~hourly), so callers do not manage token lifetime.

Outcomes are returned as a structured FcmResult with an FcmStatus
enum (SENT / UNREGISTERED / INVALID_ARGUMENT / AUTH_FAILED /
RATE_LIMITED / SERVER_ERROR / NOT_CONFIGURED / NETWORK_ERROR) and a
token_invalidated convenience property — the dispatch path uses that
to drop dead tokens without parsing FCM error envelopes itself.

Configuration via two new env vars, both optional in dev:

- BRAIN3_FCM_PROJECT_ID
- BRAIN3_FCM_SERVICE_ACCOUNT_JSON_PATH

When either is unset the function returns NOT_CONFIGURED instead of
attempting a send, mirroring the BRAIN3_APP_BEARER_TOKEN graceful-
degradation pattern. .env.example documents both.

google-auth is imported lazily so tests that mock the send path do
not need it on the import path.
New app/services/delivery.py::dispatch_push builds a data-only FCM v1
payload — notification_id, notification_type, message, canned_responses
(JSON-encoded), scheduled_date, rule_id; android.priority HIGH — and
broadcasts it to every row in app_devices. Tokens that come back
UNREGISTERED or INVALID_ARGUMENT are hard-deleted from app_devices so
a stale device does not keep generating dispatch attempts. Per-device
errors are caught in the loop so one bad device cannot block the
others.

The data-only shape is the [2C-06] contract: putting content in the
notification field would let Android auto-render a no-action toast and
the @capacitor/push-notifications plugin would never see the data.

Two transition surfaces wire dispatch_push:

- app/routers/notification.py PATCH handler — synchronous after the
  status='delivered' branch's expires_at recomputation. Acceptable at
  Phase 2 single-user scale; async/queue is a Chunk 5 concern. A
  raising dispatch is logged and never escalated to a 500.
- app/services/delivery_promoter.py — promote_due_notifications calls
  dispatch_push for each row it transitions. This satisfies the
  [2C-05] AC #1 obligation that was carried forward from the
  [2C-05a] PR #212 deviation D-18 (FCM dispatch verification deferred
  until the dispatch path existed). Per-row failures are caught and
  logged so one bad row never stops the rest of the batch.
…205)

tests/test_devices.py — happy path, upsert, validation, list, delete,
and direct ORM checks on AppDevice. Bearer-auth integration covered
via a fresh FastAPI app with AppBearerAuthMiddleware mounted (the
default test client runs with the token unset by design).

tests/test_delivery_dispatch.py — payload-shape contract with [2C-06]
(data-only, android.priority HIGH, all data values strings, json-
encoded canned_responses), broadcast across devices, hard-delete on
UNREGISTERED / INVALID_ARGUMENT, transient errors keep the device,
PATCH integration (status=delivered fires FCM, other field updates do
not, dispatch failure does not surface as a 500), and promoter
integration covering the [2C-05a] D-18 carry-forward (promote_due_
notifications invokes dispatch_push for each promoted row, dispatch
failure does not block the row promotion, no-devices case still
promotes).

tests/test_fcm_client.py — is_configured gating for both env vars,
NOT_CONFIGURED return without attempting a send, and the FCM v1 error-
envelope -> FcmStatus classifier (UNREGISTERED, INVALID_ARGUMENT,
AUTH_FAILED, RATE_LIMITED, SERVER_ERROR, plus 404/400-without-detail
defensive paths). The actual HTTPS round-trip is mocked at the
dispatch layer; CI never hits live FCM.
@WilliM233
WilliM233 merged commit cdb7a10 into develop Apr 27, 2026
2 checks passed
@WilliM233
WilliM233 deleted the feat/2C-05-fcm-dispatch branch April 27, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[2C-05] Device registration endpoint, FCM client service, and delivery-time dispatch

1 participant