critical priority medium complexity infrastructure pending backend specialist Tier 1

Acceptance Criteria

FCM dispatch method accepts a resolved FCM token string and a structured payload map and returns a typed DispatchResult (success | failure | invalid_token | dead_letter)
Token validity check rejects null, empty, or malformed tokens before making any HTTP call and returns DispatchResult.invalidToken without logging an error
HTTP 200 from FCM API results in DispatchResult.success with message_id extracted from response body
HTTP 400 (invalid registration) and HTTP 404 (unregistered token) result in DispatchResult.invalidToken and trigger downstream token removal logic
HTTP 401/403 from FCM results in DispatchResult.failure with error code FCM_AUTH_ERROR and does not retry
HTTP 429 (rate limited) results in DispatchResult.failure with error code FCM_RATE_LIMITED and is eligible for retry
HTTP 500-599 from FCM results in DispatchResult.failure with error code FCM_SERVER_ERROR and is eligible for retry
Network timeout results in DispatchResult.failure with error code NETWORK_TIMEOUT and is eligible for retry
Every dispatch attempt is logged with: timestamp, recipient user_id (not the token itself), outcome code, FCM message_id on success, HTTP status on failure
FCM token is never written to application logs at any log level
Dispatch method is side-effect-free aside from the HTTP call and logging — no database writes occur here

Technical Requirements

frameworks
Flutter
Dart
Riverpod
BLoC
apis
FCM HTTP v1 API (googleapis.com/fcm/v1/projects/{project}/messages:send)
Supabase Edge Functions (as the secure server-side caller — FCM server key must never exist in client)
data models
FcmDispatchPayload
DispatchResult
FcmErrorCode enum
performance requirements
HTTP call must complete within 5 seconds before timeout is triggered
Dispatch method must not block the calling isolate — use async/await throughout
No synchronous I/O in dispatch path
security requirements
FCM server credentials (service account key or server key) must reside only in Supabase Edge Function environment variables — never in Flutter client code or Dart source
FCM token must be treated as a sensitive identifier: never logged, never sent to analytics
All FCM API calls must be made over TLS 1.2+
Authorization header must use short-lived OAuth2 access tokens (FCM HTTP v1), not legacy server keys

Execution Context

Execution Tier
Tier 1

Tier 1 - 540 tasks

Can start after Tier 0 completes

Implementation Notes

This method must execute server-side (Supabase Edge Function in TypeScript/Deno) because FCM server credentials cannot be embedded in the Flutter client. The Flutter/Dart layer invokes the Edge Function via an authenticated Supabase RPC call, passing only the notification payload and the target user_id. The Edge Function resolves the FCM token from the database and makes the actual FCM HTTP v1 API call. Design DispatchResult as a sealed class (Dart 3) with subclasses Success, Failure, InvalidToken, DeadLetter — this makes exhaustive handling mandatory at call sites.

Use http package or dio for the HTTP call; set connectTimeout and receiveTimeout explicitly. Extract FCM response parsing into a private pure function for testability. Log structured JSON (not freeform strings) to make log aggregation (e.g. Supabase log drain) parseable.

Never log the token — log a hashed or truncated token fingerprint if correlation is needed for debugging.

Testing Requirements

Unit tests using flutter_test and a mocked HTTP client (mockito or manual stub). Required test cases: (1) successful dispatch returns DispatchResult.success with correct message_id, (2) empty token returns DispatchResult.invalidToken without HTTP call, (3) FCM 400 returns DispatchResult.invalidToken, (4) FCM 500 returns DispatchResult.failure with FCM_SERVER_ERROR, (5) network timeout returns DispatchResult.failure with NETWORK_TIMEOUT, (6) FCM 429 returns DispatchResult.failure with FCM_RATE_LIMITED. Verify that no test case writes the raw FCM token to any log output. Aim for 100% branch coverage of the dispatch method.

Component
FCM Notification Dispatcher
infrastructure medium
Epic Risks (3)
high impact medium prob integration

The org membership table structure used to resolve coordinator relationships may differ from what the repository assumes, causing incorrect coordinator lookup or missing rows for mentors in multi-chapter scenarios.

Mitigation & Contingency

Mitigation: Review the existing org membership table schema and RLS policies before writing repository queries. Align query logic with the patterns already used by peer-mentor-status-repository and multi-chapter-membership-service.

Contingency: If schema differs, add an adapter layer in the repository that normalises the membership resolution and document the discrepancy for the data team. Fall back to coordinator lookup via the feature's own stored coordinator_id field if org membership join fails.

high impact medium prob technical

Device tokens stored in the database may be stale or unregistered, causing FCM dispatch failures that silently drop coordinator notifications — the primary coordination safeguard of this feature.

Mitigation & Contingency

Mitigation: Implement token validation on every dispatch call and handle FCM's NOT_REGISTERED error by flagging the token as invalid in the database. Reuse the token refresh pattern already established by fcm-token-manager.

Contingency: If push delivery fails after retry, ensure the in-app notification record is always written regardless of push outcome so coordinators can still see the event in the notification centre.

medium impact low prob technical

The optional reason field may contain special characters, emoji, or non-Latin scripts that exceed the 200-character byte limit when FCM encodes the payload, causing delivery failures.

Mitigation & Contingency

Mitigation: Enforce the 200-character limit on Unicode code point count, not byte count, in the payload builder. Add a unit test with multi-byte input strings.

Contingency: If an oversized payload is detected at dispatch time, strip the reason field from the push notification body and note 'See in-app notification for full reason' to preserve delivery.