critical priority medium complexity backend pending backend specialist Tier 3

Acceptance Criteria

`computeExpiryState(Certification cert)` is a pure synchronous function with no I/O or side effects
Returns `CertificationStatus.expired` when `cert.expiresAt` is strictly before `DateTime.now()`
Returns `CertificationStatus.expiring_soon` when `cert.expiresAt` is on or after `DateTime.now()` but within 30 calendar days
Returns `CertificationStatus.valid` when `cert.expiresAt` is more than 30 days in the future
The 30-day threshold constant is defined as a named constant (e.g., `kExpiringSoonThresholdDays = 30`) in a constants file, not hardcoded inline
Function handles a null or missing `expiresAt` gracefully — returns `CertificationStatus.unknown` or throws a descriptive exception (team decision, documented in code comment)
All comparisons use UTC to avoid daylight saving time edge cases
CertificationStatus enum is exported from the domain layer and consumed by both the BLoC and the UI layer without re-definition
Unit tests cover all boundary conditions including same-day expiry and exactly-30-days-out cases

Technical Requirements

frameworks
Dart
data models
certification
performance requirements
Function must execute in sub-millisecond time as it may be called in list-rendering loops for large mentor sets
security requirements
All DateTime comparisons use UTC to prevent timezone-based status manipulation

Execution Context

Execution Tier
Tier 3

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.

Component
Certification Management Service
service high
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.