## Plan: Attendance Shift Reminders (pg_cron / Supabase-scheduled) TL;DR — Use `pg_cron` in your Supabase Postgres to schedule a SQL job every minute that enqueues due notifications into a Postgres table (`scheduled_notifications`). A lightweight processor (preferred: Cloud Run worker) will pick up queued notifications, deduplicate via `notification_pushes`/`try_mark_notification_pushed`, and call the existing `send_fcm` function to deliver pushes. This makes scheduling fully server-side and reliable even when user devices are offline. **High-level approach** - Use Postgres-side scheduling (`pg_cron`) to run a stored procedure every minute. The stored procedure finds: - shifts starting in ~15 minutes (15-minute reminder), and - shifts ending now (exact end reminder). - The procedure enqueues one row per user per reminder into `scheduled_notifications` (idempotent insert). - A processor service (Cloud Run worker or Edge Function) polls or streams `scheduled_notifications` rows, marks them using `try_mark_notification_pushed(notification_id)` and calls `send_fcm` for delivery. **Why this design?** - `pg_cron` keeps scheduling close to data and avoids external cron reliance. - Enqueue + processor splits concerns: SQL is simple and reliable; sending FCM is a network task better handled in application code. - Deduplication and skip-if-checked logic are enforced before enqueue and again at send-time for safety. **Detailed Steps** 1) Add DB schema and helper functions - `notification_pushes` (dedupe table) — if not present, create: CREATE TABLE IF NOT EXISTS notification_pushes ( notification_id text PRIMARY KEY, created_at timestamptz DEFAULT now(), payload jsonb ); - `scheduled_notifications` (queue table): CREATE TABLE IF NOT EXISTS scheduled_notifications ( id bigserial PRIMARY KEY, schedule_id bigint NOT NULL, user_id uuid NOT NULL, notify_type text NOT NULL, -- 'start_15' | 'end' scheduled_for timestamptz NOT NULL, created_at timestamptz DEFAULT now(), processed boolean DEFAULT false, processed_at timestamptz, retry_count int DEFAULT 0, last_error text ); CREATE INDEX ON scheduled_notifications (processed, scheduled_for); ALTER TABLE scheduled_notifications ADD CONSTRAINT IF NOT EXISTS uniq_sched_user_type UNIQUE (schedule_id, user_id, notify_type); - `try_mark_notification_pushed` SQL helper (if not present): CREATE OR REPLACE FUNCTION try_mark_notification_pushed(nid text, payload jsonb DEFAULT '{}'::jsonb) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN INSERT INTO notification_pushes(notification_id, payload) VALUES (nid, payload); RETURN TRUE; EXCEPTION WHEN unique_violation THEN RETURN FALSE; END; $$; 2) Stored procedure to enqueue due reminders - Function: `enqueue_due_shift_notifications()` - Behavior: - Compute current UTC `now()`. - For 15-minute reminders: find `duty_schedules` where `start_at` BETWEEN now() + interval '15 minutes' - interval '30 seconds' AND now() + interval '15 minutes' + interval '30 seconds' (1-minute window). Adjust tolerance as desired. - For end reminders: find `duty_schedules` where `end_at` BETWEEN now() - interval '30 seconds' AND now() + interval '30 seconds'. - For each schedule, resolve the assigned `user_id` (or multiple participants). - Skip enqueue if `attendance_logs` already shows a check-in for that schedule and remind type is `start_15` (you selected this behavior). - Insert into `scheduled_notifications` with `ON CONFLICT DO NOTHING` to avoid duplicates. - Example function outline (PL/pgSQL): CREATE OR REPLACE FUNCTION enqueue_due_shift_notifications() RETURNS void LANGUAGE plpgsql AS $$ DECLARE rec RECORD; BEGIN FOR rec IN SELECT id AS schedule_id, assigned_user_id AS user_id, start_at FROM duty_schedules WHERE start_at BETWEEN now() + interval '15 minutes' - interval '30 seconds' AND now() + interval '15 minutes' + interval '30 seconds' AND status = 'active' LOOP IF EXISTS (SELECT 1 FROM attendance_logs al WHERE al.schedule_id = rec.schedule_id AND al.type = 'check_in') THEN CONTINUE; END IF; INSERT INTO scheduled_notifications (schedule_id, user_id, notify_type, scheduled_for) VALUES (rec.schedule_id, rec.user_id, 'start_15', rec.start_at) ON CONFLICT (schedule_id, user_id, notify_type) DO NOTHING; END LOOP; FOR rec IN SELECT id AS schedule_id, assigned_user_id AS user_id, end_at FROM duty_schedules WHERE end_at BETWEEN now() - interval '30 seconds' AND now() + interval '30 seconds' AND status = 'active' LOOP INSERT INTO scheduled_notifications (schedule_id, user_id, notify_type, scheduled_for) VALUES (rec.schedule_id, rec.user_id, 'end', rec.end_at) ON CONFLICT (schedule_id, user_id, notify_type) DO NOTHING; END LOOP; END; $$; 3) Schedule `pg_cron` job - Enable `pg_cron` (Supabase project admin). Schedule: SELECT cron.schedule('shift_reminders_every_min', '*/1 * * * *', $$SELECT enqueue_due_shift_notifications();$$); - Monitor `cron.job` and `cron.job_run_details` tables for health. 4) Processor options (choose one) - Option A — Cloud Run / long-running worker (recommended): - Worker responsibilities: - Poll `scheduled_notifications WHERE processed = false AND scheduled_for <= now()` in batches, or LISTEN for notifications. - For each row: build `notification_id = 'shift-' || schedule_id || '-' || user_id || '-' || notify_type`. - Call `SELECT try_mark_notification_pushed(notification_id, to_jsonb(row))`; if it returns false, skip (already sent). - Otherwise, fetch FCM tokens for `user_id` from `fcm_tokens` table, and call existing `send_fcm` edge function (HTTP) with payload {user_id, title, body, data, notification_id} batching tokens if needed. - Mark scheduled row processed (processed = true, processed_at = now()). On transient failure increment `retry_count` and update `last_error`. - Deploy worker to Cloud Run with `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SEND_FCM_URL` as secrets. - Option B — Supabase Edge Function polling (if you prefer serverless): - Deploy an Edge Function `process_scheduled_notifications` that runs quickly and processes a batch then exits. Use `pg_cron` to call it via HTTP if `pg_http` is enabled, or use GitHub Actions scheduled trigger to call it frequently. Less real-time but serverless-friendly. - Option C — Postgres direct HTTP (only if `pg_http` / `pg_net` available): - Extend enqueue function to directly POST to `send_fcm` endpoint. Not recommended unless your Supabase instance supports and you accept putting external calls in DB. 5) Security & roles - `pg_cron` runs as the role that created the cron job; ensure it has SELECT on `duty_schedules` and INSERT on `scheduled_notifications`. - Processor uses `SUPABASE_SERVICE_ROLE_KEY` for API calls and to read `fcm_tokens`. 6) Observability, retries, and failure handling - Add a `retry_count` and `last_error` to `scheduled_notifications` (already included) for DB-visible retries. - Structured logs in the processor: `notification_id`, `schedule_id`, `user_id`, token_count, status. - Monitor `scheduled_notifications` rows with `retry_count` > threshold. 7) Tests & verification - Unit tests for `enqueue_due_shift_notifications()`. - Manual test: insert a test `duty_schedules` record 15 minutes ahead; run the function manually; assert a row in `scheduled_notifications`. - Run processor in staging and confirm `notification_pushes` has the `notification_id` and device receives the push. **Files / artifacts to add** - `supabase/migrations/xxxx_add_scheduled_notifications.sql` — create tables and helper functions above. - `supabase/migrations/xxxx_add_notification_pushes.sql` — if missing (or reuse existing migration). - `tools/notification_processor/` — worker service (Node or Dart) with Dockerfile for Cloud Run. - `DEPLOYMENT.md` — enable `pg_cron`, run migrations, deploy processor, and set secrets. **Operational notes** - Ensure `start_at` / `end_at` columns are `timestamptz` and queries are UTC-aware. - Skip-if-checked enforced at enqueue; processor double-checks dedupe before sending. - Tune time window and polling batch sizes for expected scale. **Verification checklist** 1. Enable `pg_cron` and run migrations to create `scheduled_notifications` and `notification_pushes`. 2. Manually call `enqueue_due_shift_notifications()` and confirm expected rows are created. 3. Start processor locally against staging DB; verify processing updates `notification_pushes` and calls `send_fcm` (mock/staging endpoint). 4. Validate on-device push delivery and skip-if-checked behavior. **Decision / assumptions** - Preferred flow: `pg_cron` enqueue + Cloud Run processor. - If DB `http` extensions are available and approved, a simpler single-step approach is possible but less portable. - You asked to skip reminders if user already checked in — implemented at enqueue time.