high priority medium complexity backend pending backend specialist Tier 3

Acceptance Criteria

A certification_expiry_cron_log table exists in the Supabase database with columns: id (uuid PK), certification_id (uuid FK → certifications.id), action_type (text: 'reminder_30d' | 'reminder_14d' | 'reminder_7d' | 'auto_pause'), run_date (date), created_at (timestamptz default now())
A unique constraint exists on (certification_id, action_type, run_date) to enforce database-level idempotency
An isAlreadyProcessed(supabase, certificationId, actionType, runDate) function is exported from supabase/functions/certification-expiry-cron/idempotency.ts and queries the log table for an existing record
A markAsProcessed(supabase, certificationId, actionType, runDate) function is exported from the same module and inserts a record into the log table
Before each service call in the fan-out, isAlreadyProcessed is called; if it returns true, the service call is skipped and a SKIP log entry is emitted
After each successful service call, markAsProcessed is called to record the action
If markAsProcessed fails due to a unique constraint violation (race condition on concurrent invocations), the error is caught and treated as success (already processed by the concurrent invocation)
If the cron function is invoked twice on the same UTC date, the second invocation processes zero certifications (all skipped) and returns HTTP 200 with a body indicating { skipped: N, processed: 0 }
A migration file exists at supabase/migrations/YYYYMMDDHHMMSS_create_certification_expiry_cron_log.sql creating the table and unique constraint
A Deno unit test in idempotency_test.ts asserts that isAlreadyProcessed returns true when a log record exists and false when it does not
A Deno unit test asserts that a unique constraint violation from markAsProcessed is handled gracefully without throwing

Technical Requirements

frameworks
Supabase Edge Functions (Deno)
supabase-js v2
Supabase PostgreSQL 15
apis
Supabase PostgREST REST API (for log table operations)
Supabase Migrations CLI
data models
certification
performance requirements
isAlreadyProcessed query must use the unique index on (certification_id, action_type, run_date) — no sequential scans
Idempotency check overhead per certification must be under 50ms including network round-trip
security requirements
certification_expiry_cron_log table is only accessible via service role — RLS policy denies all access to anon and authenticated roles
No PII stored in the log table — only UUIDs, action type string, and dates
Log records must not be deletable by any non-service-role principal to preserve audit integrity

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Create the migration file first and run supabase db push locally to verify the table and constraint are created correctly before writing the idempotency module. In idempotency.ts, use a single .select('id').eq('certification_id', id).eq('action_type', action).eq('run_date', date).maybeSingle() query for the check — maybeSingle() returns null rather than throwing when no row is found, making the check cleaner. For markAsProcessed, use .insert({ certification_id, action_type, run_date }).select() and check for a 23505 PostgreSQL error code (unique_violation) in the error response to distinguish a race-condition duplicate from a genuine insert failure. Run the idempotency check and mark in the same logical unit as the service call: check → call service → mark.

Do not mark before the service call succeeds, as a failed service call that is marked as processed would never be retried. Consider adding a status field to the log table ('success' | 'failed') to support future retry logic.

Testing Requirements

Write Deno unit tests in supabase/functions/certification-expiry-cron/idempotency_test.ts. Mock the Supabase client to return an existing row for isAlreadyProcessed and assert it returns true. Mock to return empty rows and assert it returns false. For markAsProcessed, mock a successful insert and assert no error is thrown.

Mock a 409/unique-constraint error response and assert the error is swallowed without re-throwing. Write an integration test using a local Supabase instance: invoke the cron function twice on the same date and assert the certification_expiry_cron_log table contains exactly one row per (certification_id, action_type, run_date) combination after both invocations. Assert that the second invocation's response body contains processed: 0 and skipped: N where N equals the number of certifications in the windows.

Component
Certification Expiry Nightly Cron Job
infrastructure medium
Epic Risks (2)
medium impact low prob technical

Supabase Edge Functions can have cold-start latency that causes the nightly cron to time out when processing large cohorts of expiring certifications, resulting in partial reminder dispatches.

Mitigation & Contingency

Mitigation: Batch the cron processing in chunks of 50 mentors per iteration. Use pagination with a cursor to resume processing if the function is re-invoked. Keep total invocation time well under the Edge Function timeout limit.

Contingency: If timeouts occur in production, split the cron into two separate functions: one for reminders and one for auto-pauses, each with its own schedule offset to reduce peak load.

low impact medium prob technical

Certification BLoC covers three distinct workflows (view, renew, enrol) which may lead to an overly complex state machine that is hard to test and maintain, particularly when error states from multiple concurrent operations need to be differentiated in the UI.

Mitigation & Contingency

Mitigation: Use separate sealed state classes per workflow (CertificationViewState, RenewalState, EnrolmentState) composed into a single BLoC state wrapper. Follow the existing BLoC patterns established in the codebase for consistency.

Contingency: If the BLoC grows too complex, split into two BLoCs: CertificationBLoC (view/load) and CertificationActionBLoC (mutations), connected via a shared stream.