critical priority medium complexity backend pending backend specialist Tier 4

Acceptance Criteria

buildMentorReminderPayload(CertificationExpiryCandidate candidate) returns a typed FcmPayload with title, body, data map, and fcmTarget (device token or topic)
Mentor payload body includes the number of days remaining until expiry (e.g., 'Your certification expires in 7 days') and a course enrolment deep-link
buildCoordinatorReminderPayload(List<CertificationExpiryCandidate> candidates, String coordinatorId) batches all expiring mentors for a given coordinator into a single notification body listing mentor names/counts
Coordinator payload is limited to 50 mentor names in the body; if more exist, body reads '… and X more' to avoid FCM 4KB payload limit
Both builders produce an FcmPayload with a data field containing: notification_type='cert_expiry_reminder', deep_link (URL scheme string), threshold_days (int), and cert_type (string) — these are used by the Flutter app to route on notification tap
Deep-link in mentor payload routes to the certification detail screen (/certification/{certId}); coordinator payload routes to the team certification overview screen (/team/certifications)
Neither payload contains PII beyond the mentor's first name and last initial in the coordinator summary (e.g., 'Ola N.')
Payloads are serialisable to JSON without loss (tested via jsonEncode/jsonDecode round-trip)
buildCoordinatorReminderPayload groups candidates by coordinatorId internally and returns a Map<String, FcmPayload> keyed by coordinatorId
Builders are pure functions with no side effects — they do not call any external API or database

Technical Requirements

frameworks
Dart (pure, no Flutter dependency)
Firebase Cloud Messaging (FCM) API v1 — payload must conform to FCM v1 message format
Riverpod for injection of any config dependencies (e.g., deep-link base URL)
apis
Firebase Cloud Messaging (FCM) API v1 — payload schema reference for android/apns/notification/data fields
Supabase Edge Functions (Deno) — these builders are invoked within the Edge Function before dispatch
data models
CertificationExpiryCandidate (mentorId, coordinatorId, expiryDate, thresholdBucket, certType)
FcmPayload (value object: title, body, data: Map<String, String>, fcmTarget: String, platform: enum(android, ios, both))
performance requirements
buildCoordinatorReminderPayload must handle 200 candidates and produce all coordinator payloads in under 100ms (pure in-memory grouping)
Total serialised payload size must not exceed 4KB (FCM limit) — enforce with an assertion in debug builds
security requirements
No personnummer, full email address, or complete last name in any FCM payload body or data field
FCM server key and service account credentials are injected server-side (Edge Function env vars) — builders receive only logical targets (device tokens), not credentials
Notification payloads contain minimal data — full content fetched from API on notification open (data-only push preferred over notification push for sensitive context)
Deep-link URLs validated against an allowlist of known app URL schemes before embedding in payload

Execution Context

Execution Tier
Tier 4

Tier 4 - 323 tasks

Can start after Tier 3 completes

Implementation Notes

Define FcmPayload as an immutable Dart class with a toJson() method that produces the FCM v1 message body structure. The FCM v1 format requires a 'message' wrapper with 'notification' (title/body) and 'data' (string key-value pairs) — ensure all data map values are explicitly cast to String since FCM rejects non-string data values. For the coordinator batching, use a groupBy utility (from the collection package) to group candidates by coordinatorId in a single pass before building payloads. Implement a _formatMentorNameShort(String firstName, String lastName) private helper that produces 'Ola N.' format — centralise PII truncation logic here.

The 4KB limit check should be implemented as an assert in debug mode and a logged warning (not throw) in release mode to avoid crashing the reminder pipeline over a marginal overage. Use separate builder methods (not a generic builder with an audience flag) for clarity — the two payloads have structurally different inputs.

Testing Requirements

Unit tests (flutter_test): (1) buildMentorReminderPayload with a 7-day candidate; assert body contains '7 days', deep_link routes to /certification/{certId}, data map contains threshold_days=7 and cert_type. (2) buildMentorReminderPayload with 14-day and 30-day candidates; assert correct day counts and threshold values. (3) buildCoordinatorReminderPayload with 60 candidates for one coordinator; assert body contains '… and X more' and total payload < 4KB. (4) Assert PII check: no '@' (email) and no digit sequences >5 chars (personnummer proxy) in any payload field.

(5) jsonEncode/jsonDecode round-trip test for both payload types. (6) buildCoordinatorReminderPayload with candidates for 3 different coordinators; assert Map contains 3 keys with correct grouping. (7) Empty candidate list to coordinator builder returns empty Map. Coverage target: 90% line coverage.

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.