critical priority medium complexity infrastructure pending infrastructure specialist Tier 0

Acceptance Criteria

sendCertificateExpiryNotification(mentorFcmToken, thresholdDays, certificateType) dispatches a well-formed FCM message with a localised title, body, and data payload indicating the threshold (60/30/7/lapsed) and certificate type
The FCM payload includes a data field notification_type: 'certificate_expiry', threshold_days: '<value>', and certificate_type: '<value>' so the Flutter app can route the notification to the correct screen
On FCM HTTP 500 or 503 responses, the sender retries up to 3 times with exponential back-off (1s, 2s, 4s); after exhausting retries it logs the failure and re-throws FcmDeliveryError
On FCM HTTP 404 (invalid/unregistered token), the sender does NOT retry, marks the token as invalid, and logs the event — it does not throw an unhandled exception
Every delivery attempt (success or failure) is logged to the delivery_attempts table via the DeliveryAttemptRepository with correct status, attempt_number, and error_code
The sender is implemented as an injectable Dart class (FcmPushNotificationSender) with a mockable HTTP client so it can be tested without real FCM calls
FCM credentials (service account JSON or server key) are loaded from environment variables, never hard-coded
The sender is usable from both a Supabase Edge Function (Deno/TypeScript context) and the Flutter app's background isolate if needed — or clearly scoped to the Edge Function with a documented reason

Technical Requirements

frameworks
Dart http package (injectable HttpClient)
Supabase Edge Functions (Deno runtime for server-side FCM calls)
firebase_messaging (Flutter, for token registration side only)
apis
FCM HTTP v1 API (https://fcm.googleapis.com/v1/projects/{project_id}/messages:send)
Google OAuth2 token endpoint (for service account auth to FCM v1)
Supabase PostgREST API (delivery_attempts table write)
data models
FcmMessage (token, notification: {title, body}, data: {notification_type, threshold_days, certificate_type})
DeliveryAttempt (notification_id, user_id, fcm_token, attempt_number, status, error_code, delivered_at)
FcmDeliveryError (error type with fcm_error_code and message)
performance requirements
Single FCM send call must complete in under 3 seconds (excluding retries)
Batch of 50 FCM sends must complete in under 30 seconds using concurrent dispatch (Future.wait with controlled concurrency)
Retry back-off must not block the event loop — use Future.delayed
security requirements
FCM service account key must be loaded from Supabase Edge Function secrets (supabase secrets set FCM_SERVICE_ACCOUNT_JSON), never from code
Google OAuth2 access token for FCM v1 must be refreshed before expiry (token TTL is 1 hour); cache the token in memory for the duration of the edge function invocation
FCM tokens are PII-adjacent — do not log raw tokens; log only the first 8 characters suffixed with '***'

Execution Context

Execution Tier
Tier 0

Tier 0 - 440 tasks

Implementation Notes

Since Flutter clients cannot call Firebase Admin SDK directly, implement the FCM send logic in a Supabase Edge Function (TypeScript/Deno). The Edge Function receives { mentorId, thresholdDays, certificateType } as input, looks up the mentor's FCM token from the fcm_tokens table, builds the FCM v1 payload, and calls the FCM API using fetch with a service account OAuth2 token. Use the googleapis-auth Deno module (or a minimal OAuth2 implementation) to obtain a short-lived access token from the service account JSON. For retry back-off, use a simple loop with await new Promise(r => setTimeout(r, delay)) — do not use a heavy retry library.

Expose the Edge Function as invoke('send-certificate-expiry-notification', { body }) from the orchestrator so the orchestrator stays thin. Document the Edge Function's expected input/output contract in the inline JSDoc.

Testing Requirements

Unit tests: inject a mock HttpClient to verify correct FCM v1 HTTP request structure (method, URL, headers, body shape), verify retry is triggered exactly 3 times on 503, verify no retry on 404, verify FcmDeliveryError is thrown after max retries. Integration tests: use a Firebase test project or FCM emulator to send a real test message to a test device token; verify the delivery_attempts row is written. Verify that an invalid token results in token invalidation without an unhandled exception. All tests must be runnable in CI without a physical device.

Epic Risks (4)
high impact medium prob technical

If the daily edge function runs more than once in a 24-hour window due to a Supabase scheduling anomaly or manual re-trigger, the orchestrator could dispatch duplicate push notifications to the same mentor and coordinator for the same threshold, eroding user trust.

Mitigation & Contingency

Mitigation: Implement idempotency at the notification record level using a unique constraint on (mentor_id, threshold_days, certification_id). The orchestrator checks for an existing record before dispatching. Use a database-level upsert with ON CONFLICT DO NOTHING.

Contingency: If duplicate notifications are reported in production, add a rate-limiting guard in the edge function that aborts if a notification for the same mentor and threshold was created within the last 20 hours, and add an alerting rule to Supabase logs for duplicate dispatch attempts.

medium impact medium prob scope

The mentor visibility suppressor relies on the daily edge function to detect expiry and update suppression_status. A mentor whose certificate expires at midnight may remain visible for up to 24 hours if the cron runs at a fixed time, violating HLF's requirement that expired mentors disappear promptly.

Mitigation & Contingency

Mitigation: Schedule the edge function to run at 00:05 UTC to minimise lag after midnight transitions. Additionally, the RLS policy can include a direct date comparison (certification_expiry_date < now()) as a secondary predicate that does not rely on suppression_status, providing real-time enforcement at the database level.

Contingency: If the cron lag is unacceptable after launch, implement a Supabase database trigger on the certifications table that fires on UPDATE of expiry_date and calls the suppressor immediately, reducing lag to near-zero for renewal and expiry events.

medium impact low prob integration

The orchestrator needs to resolve the coordinator assigned to a specific peer mentor to dispatch coordinator-side notifications. If the assignment relationship is not normalised or is missing for some mentors, coordinator notifications will silently fail.

Mitigation & Contingency

Mitigation: Query the coordinator assignment from the existing assignments or user_roles table before dispatch. Log a structured warning (missing_coordinator_assignment: mentor_id) when no coordinator is found. Add a data quality check in the edge function that reports mentors without coordinators.

Contingency: If coordinator assignments are missing at scale, fall back to notifying the chapter-level admin role for the mentor's chapter, and surface a data quality report to the admin dashboard showing mentors without assigned coordinators.

medium impact low prob dependency

The course enrollment prompt service generates deep-link URLs targeting the course administration feature. If the course administration feature changes its deep-link schema or the Dynamics portal URL structure changes, enrollment prompts will navigate to broken destinations.

Mitigation & Contingency

Mitigation: Define the deep-link contract between the certificate expiry feature and the course administration feature as a shared constant in a cross-feature navigation config. Version the deep-link schema and validate the generated URL format in unit tests.

Contingency: If the deep-link breaks in production, the course enrollment prompt service should gracefully fall back to opening the course administration feature root screen with a query parameter indicating the notification context, allowing the user to manually locate the correct course.