-- Migration: Banner notification support for announcements -- Adds banner_enabled, show/hide times, and scheduled push intervals. -- Hooks into the existing scheduled_notifications queue + pg_cron pipeline. -- ============================================================================ -- 1. Banner columns on announcements -- ============================================================================ ALTER TABLE public.announcements ADD COLUMN IF NOT EXISTS banner_enabled boolean NOT NULL DEFAULT false, ADD COLUMN IF NOT EXISTS banner_show_at timestamptz, -- null = show immediately ADD COLUMN IF NOT EXISTS banner_hide_at timestamptz, -- null = manual off only ADD COLUMN IF NOT EXISTS push_interval_minutes integer; -- null = no scheduled push -- Partial index: only index rows with active banners CREATE INDEX IF NOT EXISTS idx_announcements_banner_active ON public.announcements (banner_show_at, banner_hide_at) WHERE banner_enabled = true AND push_interval_minutes IS NOT NULL; -- ============================================================================ -- 2. Extend scheduled_notifications with announcement_id -- ============================================================================ ALTER TABLE public.scheduled_notifications ADD COLUMN IF NOT EXISTS announcement_id uuid REFERENCES public.announcements(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS idx_sched_notif_announcement ON public.scheduled_notifications(announcement_id) WHERE announcement_id IS NOT NULL; -- 2a. Rebuild unique index to include announcement_id DROP INDEX IF EXISTS uniq_sched_notif; CREATE UNIQUE INDEX IF NOT EXISTS uniq_sched_notif ON public.scheduled_notifications ( COALESCE(schedule_id, '00000000-0000-0000-0000-000000000000'), COALESCE(task_id, '00000000-0000-0000-0000-000000000000'), COALESCE(it_service_request_id, '00000000-0000-0000-0000-000000000000'), COALESCE(pass_slip_id, '00000000-0000-0000-0000-000000000000'), COALESCE(announcement_id, '00000000-0000-0000-0000-000000000000'), user_id, notify_type, epoch ); -- 2b. Update CHECK constraint to allow announcement-only rows ALTER TABLE public.scheduled_notifications DROP CONSTRAINT IF EXISTS chk_at_least_one_ref; ALTER TABLE public.scheduled_notifications ADD CONSTRAINT chk_at_least_one_ref CHECK ( schedule_id IS NOT NULL OR task_id IS NOT NULL OR it_service_request_id IS NOT NULL OR pass_slip_id IS NOT NULL OR announcement_id IS NOT NULL ); -- ============================================================================ -- 3. Enqueue function for banner announcement push notifications -- ============================================================================ -- Called by enqueue_all_notifications() every minute via pg_cron. -- For each active banner announcement with a push interval, inserts one -- scheduled_notification per target user per interval epoch. -- epoch = floor(unix_seconds / interval_seconds) ensures exactly one push -- per user per interval window with ON CONFLICT DO NOTHING idempotency. CREATE OR REPLACE FUNCTION public.enqueue_announcement_banner_notifications() RETURNS void LANGUAGE plpgsql AS $$ DECLARE ann RECORD; usr RECORD; v_epoch int; BEGIN FOR ann IN SELECT a.id, a.author_id, a.visible_roles, a.push_interval_minutes FROM public.announcements a WHERE a.banner_enabled = true AND a.push_interval_minutes IS NOT NULL AND a.is_template = false AND (a.banner_show_at IS NULL OR a.banner_show_at <= now()) AND (a.banner_hide_at IS NULL OR a.banner_hide_at > now()) LOOP -- One epoch per interval window; prevents duplicate pushes within the window v_epoch := FLOOR( EXTRACT(EPOCH FROM now()) / (ann.push_interval_minutes * 60) )::int; -- Purge rows that are ≥2 epochs stale (unprocessed due to missed cycles). -- We keep epoch = v_epoch - 1 because that row's scheduled_for falls -- exactly at the current epoch boundary and is about to be processed. -- Deleting epoch < v_epoch would remove rows that are due RIGHT NOW -- (epoch E's row has scheduled_for = (E+1)*interval = now), causing -- all notifications to stop firing. DELETE FROM public.scheduled_notifications WHERE announcement_id = ann.id AND notify_type = 'announcement_banner' AND processed = false AND epoch < v_epoch - 1; FOR usr IN SELECT p.id FROM public.profiles p WHERE p.role::text = ANY(ann.visible_roles) AND p.id <> ann.author_id LOOP INSERT INTO public.scheduled_notifications (announcement_id, user_id, notify_type, scheduled_for, epoch) VALUES (ann.id, usr.id, 'announcement_banner', to_timestamp(((v_epoch + 1)::bigint * ann.push_interval_minutes * 60)), v_epoch) ON CONFLICT DO NOTHING; END LOOP; END LOOP; END; $$; -- ============================================================================ -- 4. Re-register master dispatcher to include banner announcements -- ============================================================================ CREATE OR REPLACE FUNCTION public.enqueue_all_notifications() RETURNS void LANGUAGE plpgsql AS $$ BEGIN PERFORM public.enqueue_due_shift_notifications(); PERFORM public.enqueue_overtime_idle_notifications(); PERFORM public.enqueue_overtime_checkout_notifications(); PERFORM public.enqueue_isr_event_notifications(); PERFORM public.enqueue_isr_evidence_notifications(); PERFORM public.enqueue_paused_task_notifications(); PERFORM public.enqueue_backlog_notifications(); PERFORM public.enqueue_pass_slip_expiry_notifications(); PERFORM public.enqueue_pass_slip_expired_notifications(); -- added in 20260322150000; kept here PERFORM public.enqueue_announcement_banner_notifications(); -- added in this migration END; $$;