153 lines
8.9 KiB
Markdown
153 lines
8.9 KiB
Markdown
## 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.
|
|
|