Part of the FCM push epic #24. The code lands in the cestercian/Artistant (iOS/Supabase) repo — it owns the canonical schema + Edge Functions. It lands INERT (returns 503 without the FCM secret), so iOS is unaffected and this can merge before the operator finishes Firebase setup (phase P3).
Why
Android needs FCM; iOS uses APNs. send-push today signs an APNs JWT and POSTs to api.push.apple.com only. The event/payload contract is already fixed and is reused verbatim — this only adds an FCM transport beside APNs.
Derived from Sources/Artistant/Services/PushService.swift + supabase/functions/send-push/index.ts:
- Events:
message, gig_request, booking_confirmed_client, booking_confirmed_artist, booking_reminder_24h, booking_review_request.
- Data keys:
artistant_event, artistant_thread_id, artistant_request_id, artistant_booking_id.
- The preview is already server-redacted. The message trigger sends
left(NEW.body, 140), and NEW.body is redacted by the BEFORE-INSERT redaction trigger (order-proofed by migration 0060). So the FCM body is safe on a lockscreen and no client-side redaction is needed — the anti-leakage moat holds.
Scope
1. Migration (supabase/migrations/00NN_device_tokens_fcm.sql)
alter table public.device_tokens add column if not exists fcm_token text unique;
alter table public.device_tokens alter column apns_token drop not null;
-- a row is EITHER an APNs or an FCM registration, never neither
alter table public.device_tokens add constraint device_tokens_one_token
check (apns_token is not null or fcm_token is not null);
iOS is unaffected: its device_tokens reads/writes use explicit columns (never select *) and always write apns_token. Apply dev → prod (dev first, per the 0028 convention).
2. send-push FCM branch (alongside APNs, not replacing)
- Split the recipient's
device_tokens rows: APNs for rows with apns_token, FCM for rows with fcm_token.
- FCM HTTP v1: mint an OAuth2 access token from the service-account JSON (
FCM_SERVICE_ACCOUNT secret) for scope https://www.googleapis.com/auth/firebase.messaging (cache it ~55 min), then POST https://fcm.googleapis.com/v1/projects/${FCM_PROJECT_ID}/messages:send.
- Transport decision — data-only + high priority. Send a
data-only message (NO notification block) so the Android client's onMessageReceived fires in foreground and background — letting it own the channel, suppress a notification for the thread already on screen, and route deep-links consistently. The preview is already redacted, so nothing leaks in data.
{ "message": {
"token": "<fcm_token>",
"data": { "artistant_event": "message", "title": "...", "body": "<redacted preview>",
"artistant_thread_id": "<uuid>" },
"android": { "priority": "high" } } }
(title/body ride in data since there is no notification block; the client composes the visible notification.)
- Token invalidation: FCM returns 404
UNREGISTERED/NOT_FOUND for a dead token — log it (deletion is the cleanup sweep, same as the APNs 410 handling).
- Fail-loud: if a recipient has an
fcm_token but FCM_SERVICE_ACCOUNT is unset, return 503 (so pg_net surfaces the gap) — mirrors the existing APNs-missing 503. The APNs path stays byte-for-byte unchanged.
3. Secrets (operator — tracked in P3)
FCM_SERVICE_ACCOUNT, FCM_PROJECT_ID on both Supabase projects.
Acceptance
Depends on / blocks
- Live delivery needs P3 secrets, but the code merges inert.
- Blocks P4 verification.
Part of the FCM push epic #24. The code lands in the
cestercian/Artistant(iOS/Supabase) repo — it owns the canonical schema + Edge Functions. It lands INERT (returns 503 without the FCM secret), so iOS is unaffected and this can merge before the operator finishes Firebase setup (phase P3).Why
Android needs FCM; iOS uses APNs.
send-pushtoday signs an APNs JWT and POSTs toapi.push.apple.comonly. The event/payload contract is already fixed and is reused verbatim — this only adds an FCM transport beside APNs.Derived from
Sources/Artistant/Services/PushService.swift+supabase/functions/send-push/index.ts:message,gig_request,booking_confirmed_client,booking_confirmed_artist,booking_reminder_24h,booking_review_request.artistant_event,artistant_thread_id,artistant_request_id,artistant_booking_id.left(NEW.body, 140), andNEW.bodyis redacted by the BEFORE-INSERT redaction trigger (order-proofed by migration0060). So the FCM body is safe on a lockscreen and no client-side redaction is needed — the anti-leakage moat holds.Scope
1. Migration (
supabase/migrations/00NN_device_tokens_fcm.sql)iOS is unaffected: its
device_tokensreads/writes use explicit columns (neverselect *) and always writeapns_token. Apply dev → prod (dev first, per the0028convention).2.
send-pushFCM branch (alongside APNs, not replacing)device_tokensrows: APNs for rows withapns_token, FCM for rows withfcm_token.FCM_SERVICE_ACCOUNTsecret) for scopehttps://www.googleapis.com/auth/firebase.messaging(cache it ~55 min), thenPOST https://fcm.googleapis.com/v1/projects/${FCM_PROJECT_ID}/messages:send.data-only message (NOnotificationblock) so the Android client'sonMessageReceivedfires in foreground and background — letting it own the channel, suppress a notification for the thread already on screen, and route deep-links consistently. The preview is already redacted, so nothing leaks indata.{ "message": { "token": "<fcm_token>", "data": { "artistant_event": "message", "title": "...", "body": "<redacted preview>", "artistant_thread_id": "<uuid>" }, "android": { "priority": "high" } } }(title/body ride in
datasince there is nonotificationblock; the client composes the visible notification.)UNREGISTERED/NOT_FOUNDfor a dead token — log it (deletion is the cleanup sweep, same as the APNs 410 handling).fcm_tokenbutFCM_SERVICE_ACCOUNTis unset, return 503 (so pg_net surfaces the gap) — mirrors the existing APNs-missing 503. The APNs path stays byte-for-byte unchanged.3. Secrets (operator — tracked in P3)
FCM_SERVICE_ACCOUNT,FCM_PROJECT_IDon both Supabase projects.Acceptance
device_tokenshas nullableapns_token, uniquefcm_token, and the one-token CHECK; iOS chat/push unaffected.send-pushdeploys to dev + prod; an FCM-token recipient with the secret set gets a v1 send; without the secret → 503; an APNs-token recipient is unchanged.Depends on / blocks