high priority medium complexity backend pending backend specialist Tier 3

Acceptance Criteria

Function `evaluateCertificationExpiry(userId, config, preferenceService, notificationRepo)` returns `TriggerDecision[]` — one decision per expiring certification that passes all gates
Query fetches all active certifications for the peer mentor from the `certification` table where `expires_at` is between NOW() and NOW() + config.certificationExpiryLeadDays
For each qualifying certification: check `notificationRepo.hasBeenSentSinceThreshold(userId, 'certification_expiry', cooldownCutoff)` where the cooldown is `config.cooldownByScenarioType['certification_expiry']` hours; if within cooldown, skip that certification
Each returned TriggerDecision includes: shouldTrigger=true, scenarioType='certification_expiry', metadata={ certificationId, certType, expiresAt (ISO8601 string) }, reason='certification_expiring_within_lead_time'
If preferenceService.isNotificationAllowed(userId, 'certification_expiry') returns false, all TriggerDecisions for this user have shouldTrigger=false with reason 'user_opted_out'
Already-expired certifications (expires_at < NOW()) are excluded — evaluator only handles upcoming expiry, not past expiry
Certifications where the mentor has already renewed (a newer cert of the same cert_type exists with expires_at in the future beyond the current one) are excluded from triggering
Evaluator returns an empty array (not null) when no expiring certifications are found
Edge case: mentor with two certifications both expiring within the lead window returns two TriggerDecisions (if each passes cooldown check independently)
TriggerDecision metadata does NOT include personally identifiable details — only cert ID and type

Technical Requirements

frameworks
Supabase Edge Functions (Deno)
apis
Supabase PostgreSQL 15
Microsoft Dynamics 365 REST API (read certification sync metadata)
data models
certification
assignment
performance requirements
Certification expiry query uses index on (peer_mentor_id, expires_at) — date range scan must not be a full table scan
Renewal check (newer cert exists) performed in same SQL query using a NOT EXISTS subquery to avoid multiple round trips
Batch evaluation: fetch all expiring certifications across all org mentors in one query with GROUP BY peer_mentor_id
security requirements
Certification data accessed via service role in Edge Function — never via mobile client query
cert_type and expiry date are considered health-adjacent data for disability organizations — do not include in push notification payload text; fetch fresh from API on notification open
Microsoft Dynamics sync metadata (if present) accessed server-side only — Azure AD credentials never in mobile app
organization_id filter required on all certification queries to enforce multi-tenant isolation

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

The renewal check SQL pattern: `WHERE NOT EXISTS (SELECT 1 FROM certification c2 WHERE c2.peer_mentor_id = c1.peer_mentor_id AND c2.cert_type = c1.cert_type AND c2.expires_at > c1.expires_at AND c2.expires_at > NOW())` — this excludes certs the mentor has already renewed. HLF-specific context from the workshop notes: the physical certification card is considered an 'adelsmerke' (badge of honor), so the reminder notification should be encouraging rather than alarming in tone — the notification copy layer (separate concern) should receive the cert_type to customize the message. The cooldown check for certification expiry should use a per-certification-ID cooldown if the same cert can trigger multiple reminders (e.g. 30-day and 7-day reminders) — consider storing cert_id in the notification repository `metadata` column and including it in the cooldown query filter.

This prevents the cooldown for one cert from blocking the reminder for a different cert. When Microsoft Dynamics sync is active (HLF), treat the Dynamics cert record as the authoritative source — the local `certification` table row is a cached copy; the evaluator should prefer the Dynamics-synced expiry date when available.

Testing Requirements

Unit tests with mocked Supabase client and repositories: test mentor with no expiring certs returns empty array; test mentor with one cert expiring within lead time and no cooldown returns one TriggerDecision; test mentor with cert expiring within lead time but within cooldown returns shouldTrigger=false; test mentor with already-expired cert (expires_at in the past) returns empty array; test mentor with two expiring certs where one is within cooldown returns exactly one TriggerDecision; test opted-out mentor returns all shouldTrigger=false decisions; test renewal check — mentor with an older expiring cert and a newer valid cert of the same type returns empty array (renewed). Verify ISO8601 format of expiresAt in metadata. All tests must run without network access.

Component
Scenario Trigger Engine
service high
Epic Risks (3)
high impact medium prob technical

The scenario-edge-function-scheduler must evaluate all active peer mentors within the 30-second Supabase Edge Function timeout. For large organisations, a sequential evaluation loop may exceed this limit, causing partial runs and missed notifications.

Mitigation & Contingency

Mitigation: Design the trigger engine to batch mentor evaluations using database-side SQL queries (bulk inactivity check via a single query rather than per-mentor calls), and add a performance test against 500 mentors during development. Document the evaluated mentor count per scenario type in scenario-evaluation-config to allow selective scenario execution per run.

Contingency: If single-run execution is insufficient, split evaluation into per-scenario-type scheduled functions (inactivity check, milestone check, expiry check) on separate cron schedules, dividing the computational load across multiple invocations.

high impact low prob technical

A race condition between concurrent scheduler invocations or retried cron triggers could cause the same scenario notification to be dispatched multiple times to a mentor, severely degrading trust in the feature.

Mitigation & Contingency

Mitigation: Implement cooldown enforcement using a database-level upsert with a unique constraint on (user_id, scenario_type, cooldown_window_start) so that a second invocation within the same window is rejected at the persistence layer rather than the application layer.

Contingency: Add an idempotency key derived from (user_id, scenario_type, evaluation_date) to the notification record insert; if a duplicate key violation is caught, log it as a warning and skip dispatch without error.

medium impact medium prob integration

The trigger engine queries peer mentor activity history across potentially multiple organisations and chapters. RLS policies configured for app-user roles may block the Edge Function's service-role queries, or query performance may degrade on large activity tables.

Mitigation & Contingency

Mitigation: Confirm the Edge Function runs with the Supabase service role key (bypassing RLS) and add composite indexes on (user_id, activity_date) to the activity tables before implementing the inactivity detection query.

Contingency: If service-role access is restricted by organisational policy, implement a dedicated database function (SECURITY DEFINER) that performs the inactivity aggregation and is callable by the Edge Function with limited scope.