-- Fix: eliminate double push notifications for announcement banners. -- -- Root causes addressed: -- 1. scheduled_for = now() made rows immediately eligible. Multiple consecutive -- epoch rows (e.g. E and E+1) all had scheduled_for in the past, so all were -- eligible simultaneously. ON CONFLICT DO NOTHING kept the old rows intact -- (didn't update scheduled_for to the future), so every processBatch run -- found 2+ eligible rows → 2+ FCM sends. -- Fix A (SQL): scheduled_for = start of the NEXT epoch window (future). -- Fix B (SQL): one-time DELETE of all pre-existing stale rows so the queue -- starts clean. The edge function deduplication (Fix C) also -- guards against any future accumulation. -- Fix C (TS): processBatch deduplicates announcement_banner rows by -- (announcement_id, user_id), keeping only the highest-epoch -- row and marking stale ones processed without sending FCM. -- -- 2. epoch < v_epoch (too aggressive) deleted epoch E's row at the start of -- epoch E+1 — the row that was due RIGHT NOW — stopping all notifications. -- Fix: epoch < v_epoch - 1 keeps epoch E intact for processing. -- ============================================================================ -- One-time cleanup: mark all pre-existing stale announcement_banner rows as -- processed so they are no longer eligible. These were inserted with the old -- scheduled_for = now() logic and have scheduled_for in the past, causing them -- to appear in every processBatch query and generate duplicate pushes. -- ============================================================================ DELETE FROM public.scheduled_notifications WHERE notify_type = 'announcement_banner' AND processed = false; 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 — which is the bug this replaces. 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 -- scheduled_for = start of the NEXT epoch window (not now()). -- This guarantees the row only becomes eligible AFTER the current -- epoch ends, so a concurrent or delayed edge-function instance can -- never see two different epochs' rows as simultaneously due. 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; $$;