Build CertificationReminderService threshold evaluator
epic-certification-management-core-logic-task-008 — Implement CertificationReminderService with an evaluateUpcomingExpiries method that queries the certification repository for mentors whose certifications expire within 30, 14, and 7 days from today. For each threshold, return a typed list of CertificationExpiryCandidate objects containing mentor ID, coordinator ID, expiry date, and threshold bucket.
Acceptance Criteria
Technical Requirements
Execution Context
Tier 2 - 518 tasks
Can start after Tier 1 completes
Implementation Notes
Implement the threshold bucketing as a private _assignBucket(expiryDate, referenceDate) pure function — this is the core algorithmic piece and must be unit-tested in isolation. Use Dart's Duration arithmetic rather than manual day arithmetic to avoid timezone edge cases; use UTC throughout. The Supabase query should use a single SELECT with WHERE expires_at > referenceDate AND expires_at <= referenceDate + Duration(days: 30) with an inner JOIN on assignments to get coordinator_id in one round trip. Define thresholds as a const list [30, 14, 7] at the top of the service for easy reconfiguration.
CertificationExpiryCandidate should implement == and hashCode for deduplication safety in later pipeline steps. This method is designed to be called from a Supabase Edge Function cron schedule (daily at 08:00 org timezone), so keep it free of any Flutter/platform dependencies.
Testing Requirements
Unit tests (flutter_test): (1) Seed mock CertificationRepository with certifications expiring in 29, 14, 7, 6, and 0 days relative to referenceDate; assert correct thresholdBucket assignment for each. (2) Assert mentor expiring in 6 days appears only in days7 bucket, not days14. (3) Assert already-expired certification (expiryDate yesterday) is excluded. (4) Assert empty list returned when no certs within 30 days.
(5) Assert paused mentor is included. (6) Mock repository throwing a network exception; assert CertificationReminderException is re-thrown with correct type. (7) Assert method accepts injected referenceDate (not internal DateTime.now()) for deterministic tests. Performance test: load-test with 500 mock candidates; assert completion under 3 seconds.
Coverage target: 95% line coverage.
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.
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.