critical priority medium complexity integration pending integration specialist Tier 5

Acceptance Criteria

dispatchReminders(List<CertificationExpiryCandidate> candidates) async method iterates candidates and dispatches FCM payloads via PushNotificationService.send(FcmPayload)
After each successful PushNotificationService.send(), a record is immediately written to cert_notification_log with status='delivered' — not batched at the end
If PushNotificationService.send() throws or returns a failure result, a cert_notification_log record is written with status='failed' and the error message stored in a failure_reason field
Dispatch loop continues after individual failures — a single failed send must not abort remaining candidates
Method returns a DispatchSummary value object with fields: totalDispatched (int), successCount (int), failureCount (int), failedCandidateIds (List<String>)
Coordinator payloads (batched from task-010) are dispatched once per coordinator, not once per mentor candidate — the grouping from buildCoordinatorReminderPayload is preserved
Per-record log writes are atomic (each is a separate INSERT, not a batch); this ensures partial progress is persisted if the function times out mid-run
If cert_notification_log INSERT fails for a successfully delivered push, the failure is logged to the app error logger but does NOT cause a re-dispatch attempt (push already delivered; log inconsistency is acceptable and recoverable)
dispatchReminders is idempotent when called with the same candidate list after a partial failure: already-logged (delivered/queued) candidates are skipped via the deduplication layer in task-009
Method is designed to run within a Supabase Edge Function with a 150-second timeout; total runtime for 200 candidates must not exceed 120 seconds

Technical Requirements

frameworks
Dart (Edge Function compatible — no Flutter imports)
Supabase Edge Functions (Deno) runtime
Firebase Cloud Messaging (FCM) API v1 — via PushNotificationService abstraction
apis
Firebase Cloud Messaging (FCM) API v1 — via PushNotificationService.send()
Supabase PostgreSQL — cert_notification_log (INSERT per send outcome)
Supabase PostgreSQL — device_token table (to resolve FCM token from mentor_id before dispatch)
data models
cert_notification_log (id, mentor_id, coordinator_id, expiry_date, threshold_days, cert_type, status: enum(delivered, queued, failed), sent_at, failure_reason)
device_token (user_id, token, platform: enum(ios, android), registered_at, is_active)
DispatchSummary (value object: totalDispatched, successCount, failureCount, failedCandidateIds)
performance requirements
Use parallel dispatch with a concurrency limit of 10 (Future.wait with chunked batches) to stay within FCM rate limits and Edge Function timeout
Device token lookup batched in a single query before the dispatch loop (not per-candidate)
Total dispatch for 200 candidates must complete within 120 seconds (within Edge Function 150s limit)
security requirements
FCM server key and service account credentials accessed from Edge Function environment variables only — never passed as method parameters
cert_notification_log writes use service role key server-side — RLS bypassed only in Edge Function context, never in mobile client
failure_reason field must not store full stack traces or SQL error messages that could expose schema details — truncate to a safe summary string
Device tokens treated as sensitive identifiers — never logged to console in production Edge Function

Execution Context

Execution Tier
Tier 5

Tier 5 - 253 tasks

Can start after Tier 4 completes

Implementation Notes

Implement the dispatch loop using Stream.fromIterable(candidates).asyncMap() with a throttle, or more practically, chunk the list into groups of 10 and use Future.wait() per chunk — this gives controlled concurrency without a full async stream overhead. The per-record log write (not batch) is critical for fault tolerance: if the Edge Function is killed at candidate 150 of 200, the first 150 are already logged as delivered and will be skipped on the next cron run via deduplication. Resolve all device tokens in a single SELECT WHERE user_id IN (...) before the loop and build a Map (mentorId → fcmToken) for O(1) lookups. When a mentor has no device token (never logged in on mobile), log a 'failed' record with failure_reason='no_device_token' — this is a legitimate failure mode that coordinators may need to act on.

The coordinator grouping: iterate the Map from task-010 in a separate loop from the mentor loop to avoid double-counting in DispatchSummary.

Testing Requirements

Unit tests (flutter_test): (1) Mock PushNotificationService returning success for all candidates; assert cert_notification_log receives one 'delivered' INSERT per candidate. (2) Mock PushNotificationService failing for candidates 2 and 4 in a list of 5; assert loop completes all 5, candidates 2 and 4 logged as 'failed', others as 'delivered', DispatchSummary has successCount=3 and failureCount=2. (3) Mock cert_notification_log INSERT failing after a successful push; assert no re-dispatch occurs and error is logged. (4) Assert coordinator payload dispatched once per coordinator, not once per underlying mentor candidate.

(5) Simulate 200 candidates with 10-concurrent dispatch; assert all complete within test timeout. Integration tests against local Supabase: verify cert_notification_log entries persist correctly; verify idempotency — re-running with the same candidates after deduplication results in zero new dispatches. Coverage target: 90% branch coverage on dispatch loop.

Component
Certification Reminder Service
service medium
Epic Risks (2)
high impact medium prob technical

The auto-pause workflow requires CertificationManagementService to call PauseManagementService and HLFDynamicsSyncService in the same logical transaction. If PauseManagementService succeeds but the Dynamics webhook fails, the mentor is paused locally but remains visible on the HLF portal.

Mitigation & Contingency

Mitigation: Implement a saga pattern: write a pending sync event to the database before calling Dynamics, and have a background retry job consume pending events. This guarantees eventual consistency even if the webhook fails transiently.

Contingency: If the Dynamics sync fails after auto-pause, surface an explicit coordinator alert in the dashboard indicating 'Dynamics sync pending — mentor may still be visible on portal'. Allow manual retry from coordinator UI.

medium impact low prob technical

If the nightly cron job runs concurrently (e.g., due to infra retry), CertificationReminderService could dispatch duplicate notifications to mentors before the cert_notification_log insert is visible to the second invocation.

Mitigation & Contingency

Mitigation: Use Supabase's upsert with a unique constraint on (mentor_id, threshold_days, cert_id) in cert_notification_log. The second concurrent insert will fail gracefully and the duplicate dispatch will be skipped.

Contingency: If duplicate notifications do reach mentors, add a post-dispatch dedup check and include a 'you may receive this notification again' disclaimer until the constraint is deployed.