Offline Support
This commit is contained in:
@@ -1,18 +1,27 @@
|
||||
-- Migration: Definitive enqueue_all_notifications() dispatcher
|
||||
-- Migration: Definitive enqueue_all_notifications() dispatcher + self-hosted fix
|
||||
--
|
||||
-- WHY THIS EXISTS:
|
||||
-- 20260322_finalize_notification_functions.sql has an underscore at character
|
||||
-- position 9 of its filename, which sorts AFTER all 20260322NNNNNN_* files
|
||||
-- (underscore ASCII 95 > digits ASCII 48-57). This means _finalize_ always
|
||||
-- runs LAST in the 20260322 group, overriding enqueue_all_notifications()
|
||||
-- with a version that omits:
|
||||
-- - enqueue_announcement_banner_notifications() (added in 20260322210000)
|
||||
-- - enqueue_pass_slip_expired_notifications() (added in 20260322150000)
|
||||
-- 1. Sort-order bug: 20260322_finalize_notification_functions.sql has an
|
||||
-- underscore at filename position 9, which sorts AFTER all 20260322NNNNNN_*
|
||||
-- files (underscore ASCII 95 > digits 48-57). It therefore ran LAST and
|
||||
-- overrode enqueue_all_notifications() with a version that omitted:
|
||||
-- - enqueue_announcement_banner_notifications() (added in 20260322210000)
|
||||
-- - enqueue_pass_slip_expired_notifications() (added in 20260322150000)
|
||||
-- This migration (20260323000000) sorts after all 20260322* files and
|
||||
-- restores the authoritative 10-type definition.
|
||||
--
|
||||
-- This migration (20260323000000) sorts AFTER all 20260322* files and
|
||||
-- redefines enqueue_all_notifications() as the single authoritative definition
|
||||
-- that includes all 10 notification types.
|
||||
-- 2. Self-hosted Docker: process_notification_queue() fires the edge function
|
||||
-- via pg_net (HTTP), but on self-hosted the vault secrets and GUC fallbacks
|
||||
-- are often not configured, so the function returns early and the edge
|
||||
-- function is never triggered. enqueue_all_notifications() therefore never
|
||||
-- runs and scheduled_notifications is never populated.
|
||||
-- Fix: a dedicated pg_cron job calls enqueue_all_notifications() directly
|
||||
-- (pure SQL, no HTTP, no vault required). The edge function is still called
|
||||
-- for FCM delivery — only the enqueue step is decoupled.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Authoritative master dispatcher (all 10 types)
|
||||
-- ============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_all_notifications()
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
@@ -28,3 +37,40 @@ BEGIN
|
||||
PERFORM public.enqueue_announcement_banner_notifications();
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Dedicated enqueue cron job (self-hosted compatible)
|
||||
-- ============================================================================
|
||||
-- This job calls enqueue_all_notifications() directly inside PostgreSQL every
|
||||
-- minute. It requires only pg_cron — no pg_net, no vault, no HTTP.
|
||||
--
|
||||
-- On Supabase cloud: both this job and the edge-function trigger run, which
|
||||
-- is safe because every enqueue function is idempotent via
|
||||
-- ON CONFLICT DO NOTHING.
|
||||
-- On self-hosted Docker: this job ensures scheduled_notifications is populated
|
||||
-- even when the pg_net → edge-function path is broken.
|
||||
-- For FCM delivery you still need the edge function to
|
||||
-- run; use the system crontab as shown below if pg_net
|
||||
-- is not configured:
|
||||
--
|
||||
-- */1 * * * * curl -sS -X POST \
|
||||
-- "http://localhost:8000/functions/v1/process_scheduled_notifications" \
|
||||
-- -H "Authorization: Bearer <service-role-key>" \
|
||||
-- -H "Content-Type: application/json" -d '{}'
|
||||
--
|
||||
-- Self-hosted vault / GUC setup (run once, required for pg_net path):
|
||||
-- SELECT vault.create_secret('http://localhost:8000', 'supabase_url');
|
||||
-- SELECT vault.create_secret('<service-role-key>', 'service_role_key');
|
||||
-- -- or via GUC (no vault extension required):
|
||||
-- ALTER DATABASE postgres SET "app.settings.supabase_url" = 'http://localhost:8000';
|
||||
-- ALTER DATABASE postgres SET "app.settings.service_role_key" = '<service-role-key>';
|
||||
DO $$
|
||||
BEGIN
|
||||
PERFORM cron.schedule(
|
||||
'notification_enqueue_every_min',
|
||||
'*/1 * * * *',
|
||||
'SELECT public.enqueue_all_notifications();'
|
||||
);
|
||||
EXCEPTION WHEN others THEN
|
||||
RAISE NOTICE 'pg_cron not available (%). Use the system crontab to call the edge function directly instead.', SQLERRM;
|
||||
END $$;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
-- Migration: Fix enum literal validation errors in enqueue functions
|
||||
--
|
||||
-- Problem: PostgreSQL validates enum literals against the enum type at
|
||||
-- execution time, even when no rows match. If a status value like 'closed'
|
||||
-- or 'in_progress_dry_run' was added to the enum in a later migration that
|
||||
-- hasn't been applied to a given deployment (e.g. self-hosted Docker), the
|
||||
-- entire enqueue_all_notifications() call aborts with:
|
||||
-- ERROR 22P02: invalid input value for enum <type>: "<value>"
|
||||
-- This prevents ALL notification types from being enqueued, not just the one
|
||||
-- that references the missing value.
|
||||
--
|
||||
-- Fix: cast the enum column to ::text before comparing. Text comparison
|
||||
-- bypasses enum validation entirely and works regardless of which enum values
|
||||
-- exist on any given deployment.
|
||||
--
|
||||
-- Affected functions and the risky literals:
|
||||
-- enqueue_overtime_checkout_notifications → task_status 'closed'
|
||||
-- enqueue_overtime_idle_notifications → it_service_request_status 'in_progress_dry_run'
|
||||
-- enqueue_isr_event_notifications → it_service_request_status 'in_progress_dry_run'
|
||||
-- enqueue_isr_evidence_notifications → it_service_request_status 'in_progress' (safe but cast for consistency)
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_overtime_checkout_notifications()
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
v_last_completed timestamptz;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT ds.id AS schedule_id, ds.user_id, al.check_in_at
|
||||
FROM public.duty_schedules ds
|
||||
JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id
|
||||
WHERE ds.shift_type = 'overtime'
|
||||
AND ds.status IN ('arrival', 'late')
|
||||
AND al.check_in_at IS NOT NULL
|
||||
AND al.check_out_at IS NULL
|
||||
AND al.check_in_at <= now() - interval '1 hour'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.task_assignments ta
|
||||
JOIN public.tasks t ON t.id = ta.task_id
|
||||
WHERE ta.user_id = ds.user_id AND t.status::text = 'in_progress'
|
||||
)
|
||||
LOOP
|
||||
SELECT MAX(t.completed_at) INTO v_last_completed
|
||||
FROM public.task_assignments ta
|
||||
JOIN public.tasks t ON t.id = ta.task_id
|
||||
WHERE ta.user_id = rec.user_id
|
||||
AND t.status::text IN ('completed', 'closed')
|
||||
AND t.completed_at IS NOT NULL
|
||||
AND t.completed_at >= rec.check_in_at;
|
||||
|
||||
IF v_last_completed IS NOT NULL AND v_last_completed <= now() - interval '30 minutes' THEN
|
||||
INSERT INTO public.scheduled_notifications
|
||||
(schedule_id, user_id, notify_type, scheduled_for)
|
||||
VALUES
|
||||
(rec.schedule_id, rec.user_id, 'overtime_checkout_30', now())
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_overtime_idle_notifications()
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT ds.id AS schedule_id, ds.user_id
|
||||
FROM public.duty_schedules ds
|
||||
JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id
|
||||
WHERE ds.shift_type = 'overtime'
|
||||
AND ds.status IN ('arrival', 'late')
|
||||
AND al.check_in_at IS NOT NULL
|
||||
AND al.check_out_at IS NULL
|
||||
AND al.check_in_at <= now() - interval '15 minutes'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.task_assignments ta
|
||||
JOIN public.tasks t ON t.id = ta.task_id
|
||||
WHERE ta.user_id = ds.user_id AND t.status::text = 'in_progress'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.it_service_request_assignments isra
|
||||
JOIN public.it_service_requests isr ON isr.id = isra.request_id
|
||||
WHERE isra.user_id = ds.user_id
|
||||
AND isr.status::text IN ('in_progress', 'in_progress_dry_run')
|
||||
)
|
||||
LOOP
|
||||
INSERT INTO public.scheduled_notifications
|
||||
(schedule_id, user_id, notify_type, scheduled_for)
|
||||
VALUES
|
||||
(rec.schedule_id, rec.user_id, 'overtime_idle_15', now())
|
||||
ON CONFLICT DO NOTHING;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_isr_event_notifications()
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT isr.id AS request_id, isr.event_date, isra.user_id
|
||||
FROM public.it_service_requests isr
|
||||
JOIN public.it_service_request_assignments isra ON isra.request_id = isr.id
|
||||
WHERE isr.status::text IN ('scheduled', 'in_progress_dry_run')
|
||||
AND isr.event_date IS NOT NULL
|
||||
AND isr.event_date BETWEEN now() + interval '60 minutes' - interval '90 seconds'
|
||||
AND now() + interval '60 minutes' + interval '90 seconds'
|
||||
LOOP
|
||||
INSERT INTO public.scheduled_notifications
|
||||
(it_service_request_id, user_id, notify_type, scheduled_for)
|
||||
VALUES
|
||||
(rec.request_id, rec.user_id, 'isr_event_60', rec.event_date - interval '1 hour')
|
||||
ON CONFLICT DO NOTHING;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_isr_evidence_notifications()
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
v_today_doy int := EXTRACT(DOY FROM now())::int;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT isr.id AS request_id, isra.user_id
|
||||
FROM public.it_service_requests isr
|
||||
JOIN public.it_service_request_assignments isra ON isra.request_id = isr.id
|
||||
WHERE isr.status::text IN ('completed', 'in_progress')
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM public.it_service_request_evidence e
|
||||
WHERE e.request_id = isr.id AND e.user_id = isra.user_id
|
||||
)
|
||||
OR
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM public.it_service_request_actions a
|
||||
WHERE a.request_id = isr.id AND a.user_id = isra.user_id
|
||||
AND a.action_taken IS NOT NULL AND TRIM(a.action_taken) != ''
|
||||
)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.attendance_logs al
|
||||
WHERE al.user_id = isra.user_id
|
||||
AND al.check_in_at::date = now()::date
|
||||
)
|
||||
LOOP
|
||||
INSERT INTO public.scheduled_notifications
|
||||
(it_service_request_id, user_id, notify_type, scheduled_for, epoch)
|
||||
VALUES
|
||||
(rec.request_id, rec.user_id, 'isr_evidence_daily', now(), v_today_doy)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Trigger to replicate attendance_check_in RPC side-effects when a new
|
||||
-- attendance_log row is inserted directly (e.g. from Brick's offline queue
|
||||
-- after sync). The RPC path is unchanged; this trigger only fires on plain
|
||||
-- INSERT and is a no-op when the RPC already handled the duty_schedule update
|
||||
-- (idempotent: ON CONFLICT (id) DO NOTHING is handled by Brick).
|
||||
--
|
||||
-- Side-effects replicated from attendance_check_in RPC:
|
||||
-- 1. duty_schedules.check_in_at ← attendance_log.check_in_at
|
||||
-- 2. duty_schedules.check_in_location ← lat/lng
|
||||
-- 3. duty_schedules.status ← 'arrival' if on time, 'late' if past start
|
||||
--
|
||||
-- For check-out, the trigger replicates attendance_check_out side-effects:
|
||||
-- 1. duty_schedules.check_out_at ← attendance_log.check_out_at
|
||||
-- 2. duty_schedules.check_out_location ← lat/lng
|
||||
-- 3. duty_schedules.status ← 'completed'
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.attendance_log_after_upsert()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_schedule duty_schedules%ROWTYPE;
|
||||
BEGIN
|
||||
-- Skip if we have no duty_schedule_id (shouldn't happen, but be defensive)
|
||||
IF NEW.duty_schedule_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_schedule FROM duty_schedules WHERE id = NEW.duty_schedule_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- ── Check-in side-effects ────────────────────────────────────────────────
|
||||
-- Only apply when check_in_at changed or this is a new row.
|
||||
IF (TG_OP = 'INSERT') OR
|
||||
(TG_OP = 'UPDATE' AND
|
||||
(OLD.check_in_at IS DISTINCT FROM NEW.check_in_at OR
|
||||
OLD.check_in_lat IS DISTINCT FROM NEW.check_in_lat)) THEN
|
||||
|
||||
-- Skip if duty_schedules already has a check_in_at (RPC already ran)
|
||||
IF v_schedule.check_in_at IS NULL THEN
|
||||
UPDATE duty_schedules
|
||||
SET
|
||||
check_in_at = NEW.check_in_at,
|
||||
check_in_location = jsonb_build_object(
|
||||
'latitude', NEW.check_in_lat,
|
||||
'longitude', NEW.check_in_lng
|
||||
),
|
||||
status = CASE
|
||||
WHEN NEW.check_in_at <= v_schedule.start_time
|
||||
THEN 'arrival'
|
||||
ELSE 'late'
|
||||
END
|
||||
WHERE id = NEW.duty_schedule_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ── Check-out side-effects ───────────────────────────────────────────────
|
||||
IF NEW.check_out_at IS NOT NULL AND
|
||||
(TG_OP = 'INSERT' OR OLD.check_out_at IS DISTINCT FROM NEW.check_out_at) THEN
|
||||
|
||||
UPDATE duty_schedules
|
||||
SET
|
||||
check_out_at = NEW.check_out_at,
|
||||
check_out_location = jsonb_build_object(
|
||||
'latitude', NEW.check_out_lat,
|
||||
'longitude', NEW.check_out_lng
|
||||
),
|
||||
status = 'completed'
|
||||
WHERE id = NEW.duty_schedule_id
|
||||
AND check_out_at IS NULL; -- idempotent: only if not already checked out
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Fire AFTER INSERT and AFTER UPDATE so both offline-queued inserts and
|
||||
-- subsequent Brick upserts (when check_out_at is filled in) are handled.
|
||||
DROP TRIGGER IF EXISTS attendance_logs_after_upsert ON attendance_logs;
|
||||
CREATE TRIGGER attendance_logs_after_upsert
|
||||
AFTER INSERT OR UPDATE ON attendance_logs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.attendance_log_after_upsert();
|
||||
Reference in New Issue
Block a user