Add FCM push notification support#251
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request implements Firebase Cloud Messaging (FCM) support for push notifications. It introduces a new fcm_device_token table, GraphQL mutations for token registration and unregistration, and the backend logic for generating OAuth2 access tokens and sending notifications via the FCM v1 API. Feedback includes improving Unicode handling during JWT encoding, parallelizing notification dispatches to reduce latency, adding a notification block to the FCM payload for better background delivery, and limiting the size of error logs when token requests fail.
- Add firebase-messaging dependency - Implement HackersPubMessagingService for receiving FCM data messages - Implement FcmTokenManager for token registration/unregistration via registerFcmDeviceToken/unregisterFcmDeviceToken GraphQL mutations - Register FCM token on login, unregister on logout - Request notification permission on login (not just app startup) - Remove foreground polling (FCM replaces it; 15-min WorkManager polling remains as fallback) - Handle GraphQL union error types in mutation responses - Add FCM mutation types to local GraphQL schema Requires server-side FCM support: hackers-pub/hackerspub#251 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dahlia
left a comment
There was a problem hiding this comment.
Thanks for your first contribution! Could you format the code using deno fmt?
This reverts commit 766ec3f.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously sendFcmNotification awaited each per-token fetch sequentially. Since createNotification awaits sendPushNotificationsBestEffort before returning, an account with many FCM tokens paid one full Google round-trip per device on the notification write path. Dispatch the per-token sends concurrently so the fan-out latency is bounded by the slowest single request. Stale-token collection and per-iteration failure logging are preserved. Also tighten FCM OAuth token response typing and FCM error body typing so `deno check` no longer flags them as `unknown`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FCM messages:send returns 404 not only for UNREGISTERED tokens but also for misconfiguration such as a wrong project_id or an unavailable FCM API for the project. Using resp.status === 404 as a standalone trigger meant one bad deployment could wipe valid device registrations and force users to re-register push notifications. Drop the status-only branch and only mark tokens stale when the errorCode explicitly says UNREGISTERED or INVALID_ARGUMENT. The test now also exercises a 404 response without the UNREGISTERED errorCode and asserts the token is retained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FCM returns INVALID_ARGUMENT not only for malformed registration tokens but also for malformed message payloads. Because every token in a fan-out shares the same payload, a payload-level validation failure would make every INVALID_ARGUMENT response delete an otherwise valid device, silently wiping out Android registrations en masse. Align FCM with the APNS path, which only marks tokens stale on strictly token-specific errors (BadDeviceToken, DeviceTokenNotForTopic, Unregistered). For FCM that means UNREGISTERED alone. Payload-caused INVALID_ARGUMENT now logs a warning and leaves the registration intact. The test now also exercises an INVALID_ARGUMENT response and asserts the token is retained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FCM registration tokens are opaque and Google explicitly warns not to validate them against any pattern because the format can change; the currently observed length already rose from 152 to 163 characters in past iterations, and the informal upper bound is the cookie size limit (~4 KB). The previous varchar(256) column therefore risks silently rejecting valid future tokens, which the model and GraphQL layers mirrored as an arbitrary length cap. Change the column to text, drop the corresponding length checks in registerFcmDeviceToken() and the GraphQL register mutation. Since the feature has not been deployed yet, regenerate the original 0090 migration in place so main gets a single clean migration instead of a varchar → text ALTER churn. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the sample only described the variable in a prose comment, so users bootstrapping from .env.sample got no placeholder to fill in and could leave FCM silently disabled. The prose also suggested pasting the raw multi-line JSON, which breaks under dotenv single-line parsing and under `\n` re-interpretation in double-quoted values. Switch to the same pattern already used for INSTANCE_ACTOR_KEY: a real placeholder entry, plus a note that the value must be single-line JSON wrapped in single quotes, with `jq -c` as the recommended way to produce it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Hi @dahlia — thanks again for the review. Just pushed a series of follow-up commits (5f9ff3e…38b8f92) addressing everything in your feedback. The full walkthrough is in the updated PR description, but a quick summary so you don't have to chase individual hashes: Your two review comments
Additional cleanup based on second-pass review
Test coverage: added Let me know if any of the policy choices (especially the |
|
@dalinaum This seems reasonable to me. I also like that this keeps the FCM policy aligned with APNS: only prune on token-specific errors. If stale-token noise becomes measurable later, we can always make the FCM heuristic more specific. |
Summary
Environment Setup
FCM requires a Firebase service account key to send push notifications. Set the
FCM_SERVICE_ACCOUNT_KEYenvironment variable with the entire service account JSON, serialized to a single line and wrapped in single quotes (same pattern used byINSTANCE_ACTOR_KEYin this repo).hackerspub-android→ Project Settings → Service Accounts.envin one shot:Why single quotes + single line:
jq -cpreserves the\nescapes insideprivate_keyas two literal characters (\+n), whichJSON.parselater restores to real newlines — this is the shape the PEM importer needs..envwould re-interpret\nto actual newlines mid-parse and breakJSON.parse.If
FCM_SERVICE_ACCOUNT_KEYis not set, FCM integration is silently disabled (APNS continues to work independently).Changes since initial review
The original feature commit lives at 8b737ba; everything below was added in response to reviewer feedback.
5f9ff3e — Revert 766ec3f (@dahlia)
The earlier "harden JWT encoding" commit UTF-8-encoded text before
btoa, but the same helper was reused on the raw RSA signature bytes, corrupting any byte > 0x7F and producing an invalid JWT — so any deployment withFCM_SERVICE_ACCOUNT_KEYconfigured would have failed to obtain an access token. The originalbtoa(str)path was already safe here (JSON payload is always ASCII; signature bytes fit Latin1), so reverting restored correct behavior.9246670 — Apply
deno fmttomodels/fcm.ts(@dahlia)Fixes the CI format check that was failing on line 70.
11d6144 — Parallelize per-token FCM dispatch with
Promise.allSettled(@dahlia, @gemini-code-assist)Because
createNotification()awaitssendPushNotificationsBestEffort(), the previous sequential per-tokenawaitfetch bound follow/share/reply/react write-path latency to the sum of Google round-trips (up to 20 sequential requests per account). Dispatching concurrently withPromise.allSettled(tokens.map(...))caps fan-out latency at the slowest single request; stale-token collection and per-iteration failure logging are preserved. OAuth/error response bodies were also properly typed sodeno checkno longer treats them asunknown. Added a unit test (models/fcm.more.test.ts) that stubsglobalThis.fetch+ generates a throwaway RSA key, tracks in-flight concurrency, and verifiesmaxInFlight > 1.c028e5b — Scope FCM stale-token pruning to
errorCodeonlyRemoved the
resp.status === 404standalone trigger: FCM also returns 404 for project misconfiguration (wrongproject_id, FCM API unavailable), and deleting tokens in that case would wipe every valid device registration on a bad deployment. Pruning is now strictly driven by the structurederrorCode.bd09c30 — Further restrict pruning to
UNREGISTEREDonlyRemoved
INVALID_ARGUMENTfrom the pruning condition. FCM returnsINVALID_ARGUMENTfor message-payload problems as well as token-format problems; since every token in a fan-out shares the same payload, a payload bug or an API-side validation change would triggerINVALID_ARGUMENTfor every token and mass-delete the entire account's Android registrations. This aligns FCM with the existing APNS behavior inmodels/apns.ts, which only marks tokens stale on strictly token-specific errors (BadDeviceToken,DeviceTokenNotForTopic,Unregistered). If orphan-token log noise becomes measurable in production, a follow-up could inspectgoogle.rpc.BadRequest.fieldViolations[].field == "message.token"for a more precise signal, but YAGNI until then. The test now also exercises anINVALID_ARGUMENTresponse and asserts the token is retained.ea47aca — Store FCM device tokens as
textrather thanvarchar(256)FCM registration tokens are opaque, and Google explicitly warns not to validate them against any pattern because the format can change (observed length already rose from 152 to 163 characters in past iterations; informal upper bound is ~4 KB). A 256-char cap risks silently rejecting valid future tokens. Switched the schema column to
text, removed the mirrored length caps inregisterFcmDeviceToken()and the GraphQL register mutation, and regenerated the original 0090 migration in place (the feature hasn't shipped yet) so main gets a single clean migration instead of avarchar → textALTER churn.38b8f92 — Make
FCM_SERVICE_ACCOUNT_KEYdiscoverable in.env.sampleThe previous sample only described the variable in a prose comment, so anyone bootstrapping from
.env.samplegot no placeholder to fill in and would leave FCM silently disabled. Switched to the same pattern used byINSTANCE_ACTOR_KEY: a real placeholder line, plus a note that the value must be single-line JSON wrapped in single quotes, withjq -cas the recommended way to produce it.Testing
deno test --env-file=.env.test --allow-all models/fcm.more.test.ts(passes).models/suite: 134/134 passing locally with a fresh Postgres 17 test DB.deno fmt --checkanddeno lint: clean for touched files.🤖 Generated with Claude Code