Implement expiry state computation logic
epic-certification-management-core-logic-task-004 — Within CertificationManagementService, implement computeExpiryState which derives the current certification state (valid, expiring_soon, expired) from the stored expiry date and current time. Define clear thresholds: expired when past expiry date, expiring_soon within 30 days, valid otherwise. Return a strongly-typed CertificationStatus enum consumed by the BLoC and UI.
Acceptance Criteria
Technical Requirements
Execution Context
Tier 3 - 413 tasks
Can start after Tier 2 completes
Implementation Notes
Extract `computeExpiryState` as a standalone pure function or static method on a `CertificationExpiryCalculator` utility class to make it independently testable without instantiating the full service. Accept an optional `DateTime Function() clock` parameter defaulting to `() => DateTime.now().toUtc()` so tests can inject a fixed time. The 30-day threshold should live in `lib/core/constants/certification_constants.dart`. When the BLoC maps certifications to UI state, it should call `computeExpiryState` on each item rather than storing the computed status in the database — computed status is derived, not stored, to avoid stale reads.
However, the `updateExpiryStatus` repository method should persist the last-computed status as a cache for server-side batch queries (Bufdir reporting, auto-pause triggers).
Testing Requirements
Write exhaustive unit tests for `computeExpiryState` covering: (1) expiry date exactly equal to now (expired), (2) expiry date one second in the past (expired), (3) expiry date one second in the future (expiring_soon, within 30 days), (4) expiry date exactly 30 days from now (expiring_soon), (5) expiry date 30 days and one second from now (valid), (6) expiry date one year in the future (valid), (7) null expiresAt (expected behaviour per team decision). Use a `FakeClock` or inject a `DateTime Function()` clock provider to make the function deterministic in tests — never call `DateTime.now()` directly inside the pure function. Verify that the CertificationStatus enum is not redefined anywhere else in the codebase.
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.