critical priority medium complexity backend pending backend specialist Tier 3

Acceptance Criteria

ReminderDispatchService queries reminder_sent_at before every dispatch attempt and returns early if a reminder was already sent within the current evaluation window
Idempotency check uses a deterministic window boundary (e.g., ISO date of the daily run) so re-runs on the same day are safely blocked
When a duplicate is detected, the method returns a typed DispatchResult.skipped value rather than throwing an exception
Skip events are logged with assignment ID, notification type, and original reminder_sent_at timestamp
Dispatch proceeds normally when no reminder exists for the current window (first-time send path)
Dispatch proceeds normally when the previous reminder_sent_at falls outside the current window (window-expiry path)
All Supabase queries for idempotency check are wrapped in try/catch and surface a typed error on failure without crashing the scheduler
No push notification is sent in any duplicate-detected scenario across both peer-mentor and coordinator notification types

Technical Requirements

frameworks
Flutter
Dart
Riverpod
apis
Supabase PostgREST REST API
Supabase Realtime (optional read)
data models
Assignment
ReminderContactTracking
ReminderConfig
performance requirements
Idempotency check must complete in a single Supabase query (no N+1 reads per assignment)
Check must not add more than 50ms overhead per assignment in the batch loop
security requirements
Supabase Row Level Security (RLS) must be enforced on the reminder_contact_tracking table so service-role reads are explicitly scoped
No personally identifiable information from the assignment is written to logs — use only assignment ID

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Model the idempotency window as an immutable value object (e.g., EvaluationWindow with a date-only boundary) injected into ReminderDispatchService so tests can control the window without time-dependent logic. Store and read reminder_sent_at from the assignment_contact_tracking table's existing column — do not introduce a separate idempotency table. Use a sealed class (DispatchResult) with variants dispatched, skipped, and failed so callers can pattern-match on outcome without relying on null checks. Keep the guard as a private _isAlreadyDispatched(String assignmentId, EvaluationWindow window) Future method to isolate the Supabase query from dispatch logic.

Avoid DateTime.now() directly in the guard — inject a Clock abstraction so tests remain deterministic.

Testing Requirements

Unit tests using flutter_test with all Supabase dependencies mocked via injectable/get_it abstractions. Required scenarios: (1) reminder_sent_at within current window → dispatch skipped, DispatchResult.skipped returned; (2) reminder_sent_at outside current window → dispatch proceeds; (3) no reminder record exists → dispatch proceeds; (4) Supabase query throws → typed error propagated, no crash; (5) coordinator escalation path blocked by idempotency check; (6) peer-mentor path blocked by idempotency check. Each test asserts the PushNotificationService mock was NOT called in skip scenarios and WAS called in proceed scenarios.

Component
Reminder Dispatch Service
service medium
Epic Risks (3)
medium impact high prob scope

The idempotency window (how long after a reminder is sent before another can be sent for the same assignment) is not explicitly specified. An incorrect window — too short, duplicate reminders appear; too long, a resolved and re-opened situation is not re-notified. This ambiguity could result in user-visible bugs post-launch.

Mitigation & Contingency

Mitigation: Before implementation, define the idempotency window explicitly with stakeholders: a reminder is suppressed if a same-type notification record exists with sent_at within the last (reminder_days - 1) days. Document this rule as a named constant in the service with a comment referencing the decision.

Contingency: If the window is wrong in production, it is a single constant change with a hotfix deployment. The notification_log table allows re-processing without data migration.

high impact medium prob technical

For organisations with thousands of open assignments (e.g., NHF with 1,400 chapters), the daily scheduler query over all open assignments could time out or consume excessive Supabase compute units, especially if the contact tracking query lacks proper indexing.

Mitigation & Contingency

Mitigation: Add a composite index on assignments(status, last_contact_date) before running performance tests. Use cursor-based pagination in the scheduler (query 500 rows at a time). Run a load test with 10,000 synthetic assignments as described in the feature documentation before merging.

Contingency: If the query is too slow for synchronous execution, move the evaluation to the Edge Function (cron trigger epic) and use Supabase's built-in parallelism. The service interface does not change, only the execution context.

medium impact medium prob integration

If the push notification service fails (FCM outage, invalid device token) during dispatch, the in-app notification may already be persisted but the push is silently lost. Inconsistent state makes it impossible to report accurate delivery status.

Mitigation & Contingency

Mitigation: Implement push dispatch and in-app persistence as separate operations with independent error handling. Record delivery_status as 'pending', 'delivered', or 'failed' on the notification_log row. Retry failed push deliveries up to 3 times with exponential backoff.

Contingency: If FCM is consistently unavailable, the in-app notification is still visible to the user, providing a degraded but functional fallback. Alert on consecutive push failures via the cron trigger's error logging.