Repository navigation
[2C-05] Device registration endpoint, FCM client service, and delivery-time dispatch - #213
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #205
Verification
ruff check .— ✅ Pass (no errors)pytest -v— ✅ Pass (1420 passed, 8 warnings; +39 new tests vs. develop's 1381)app_devicesis exercised by the postgres-backed test fixtures used elsewhere; SQLiteBase.metadata.create_allcovers the in-process test path)test_rules_postgrescontinues to pass with the new model)Ran locally against develop HEAD at
06ded81immediately 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 → deliveredtransition. Newapp_devicestable + bearer-gated CRUD endpoints; FCM HTTP v1 client built ongoogle-auth;dispatch_pushwired into both the interactive PATCH handler and the [2C-05a] delivery promoter.Changes
023_create_app_devices.py— newapp_devicestable with uniquefcm_token, platform check (android | ios), andregistered_at/last_seen_attimestamps. Phase 2 is single-user, so the table has nouser_id.app/models.py::AppDevice— SQLAlchemy 2.0 mapping mirroring the migration.app/schemas/devices.py—DeviceRegisterRequest(max 4096-charfcm_token, platform literal, optional label up to 200 chars),DeviceResponse,DeviceListResponseenvelope.app/routers/devices.py—POST /api/app/deviceswith upsert semantics (201 on insert, 200 on refresh withlast_seen_atbumped and platform/label updated),GET /api/app/deviceslisting,DELETE /api/app/devices/{id}. Mounted under the/api/app/*prefix inapp/main.py, so the existingAppBearerAuthMiddlewarefrom [2C-01] gates it without further wiring.app/services/fcm.py— single-function FCM HTTP v1 client.send_notification_to_device(fcm_token, payload) -> FcmResultposts tohttps://fcm.googleapis.com/v1/projects/{project_id}/messages:sendwith a service-account-issued OAuth2 bearer (cached on the credentials object, refreshed bygoogle-auth). Returns a structuredFcmResult(FcmStatusenum +token_invalidatedconvenience), with explicit handling for 400/INVALID_ARGUMENT, 404/UNREGISTERED, 401/403/AUTH_FAILED, 429/RATE_LIMITED, and network errors.google-authis 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-encodedcanned_responses,scheduled_date,rule_id;android.priorityHIGH) to every row inapp_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 existingstatus='delivered'expires_atrecomputation. A raisingdispatch_pushis logged and never escalated to a 500.app/services/delivery_promoter.py—promote_due_notificationsnow callsdispatch_pushfor 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— addsBRAIN3_FCM_PROJECT_IDandBRAIN3_FCM_SERVICE_ACCOUNT_JSON_PATH. Both unset in dev → FCM dispatch returnsNOT_CONFIGUREDinstead of attempting a send (mirrors theBRAIN3_APP_BEARER_TOKENgraceful-degradation pattern).requirements.txt— addsgoogle-auth==2.36.0.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-timegoogle-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
git fetch origin && git checkout feat/2C-05-fcm-dispatch && pip install -r requirements.txt(the only new dep isgoogle-auth).alembic upgrade headagainst a Postgres dev container — should apply023_create_app_devices.pycleanly.python -m ruff check .— passes clean.python -m pytest -v— 1420 tests pass (39 new).BRAIN3_APP_BEARER_TOKENset in.env, exercise the curl flow:POST /api/app/deviceswith{"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.pendingnotification todelivered— log lines fromapp.services.deliveryshow one dispatch attempt per registered device. WithoutBRAIN3_FCM_PROJECT_IDset, attempts returnNOT_CONFIGUREDand no FCM traffic leaves the box.Deviations
023_create_app_devices.py, not022. Spec called the migration022_create_app_devices.py, but022_add_scheduled_date_to_notifications.pywas added in [2C-02]: Add scheduled_date column to notification_queue and populate on creation #209 between Stellan's spec and this implementation. Used023withdown_revision="22a1b2c3d4e5"so the chain is contiguous.app/routers/notification.pylines 225–276 / line 266 for the PATCH handler and thestatus='delivered'branch. The current file is ~248–300 / line 289 after the [2C-03] idempotency and [2C-04] expiry-defaults landings. Sameupdates.get("status") == "delivered"branch — implementation follows the spec's intent against the codebase as it stands today (per agent identity's cardinal rule).pending → deliveredtransition. Wired into both surfaces with shareddispatch_pushso the v2.0.0 demo works regardless of which transition surface fires the row._tickis a sync function called betweenawait asyncio.sleepcalls, 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.datavalues coerced to strings;rule_idis""when null. FCM v1 requiresdata: map<string, string>; the spec's payload sketch showedrule_id: "<uuid-or-empty>"literally. Coerced UUID/None tostr(uuid)/""and JSON-encodedcanned_responsesso the entiredatamap is strings. The companion app native layer ([2C-06]) parses the JSON-encoded array.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 viaPOST /api/app/devices, so a dead row coming back is just a fresh insert.Test Results
The 8 warnings are pre-existing
DeprecationWarnings on@app.on_event(FastAPI lifespan event migration) — not introduced by this PR.Acceptance Checklist
AppDevicemodel tests cover insert, upsert, delete.POST /api/app/deviceswith valid bearer + payload → 201; duplicate token → 200 withlast_seen_atupdated.POST /api/app/deviceswithout bearer → 401.delivered, mock FCM client called with the expected payload shape for each registered device.UNREGISTERED→ device row removed fromapp_devices.tests/test_devices.pycovers happy path, upsert, auth failure, input validation.pending → deliveredtransition triggers FCM dispatch.Watch-Items
app/services/delivery.pyis 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.