Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
@@ -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();