UI Enhancements in IT Service Request, Announcements, Workforce and notification fixes
This commit is contained in:
@@ -63,15 +63,46 @@ async function processBatch() {
|
||||
return
|
||||
}
|
||||
|
||||
// Deduplicate announcement_banner rows: for each (announcement_id, user_id)
|
||||
// pair, keep only the row with the highest epoch and immediately mark older
|
||||
// ones as processed without sending FCM. This prevents double-pushes caused
|
||||
// by stale rows from previous epochs appearing alongside the current epoch's
|
||||
// row (e.g. if scheduled_for was set to now() instead of the next boundary,
|
||||
// or if a pg_cron cycle was missed leaving old rows unprocessed).
|
||||
const annBannerBest = new Map<string, any>()
|
||||
const staleIds: string[] = []
|
||||
for (const r of rows) {
|
||||
if (r.notify_type !== 'announcement_banner' || !r.announcement_id) continue
|
||||
const key = `${r.announcement_id}:${r.user_id}`
|
||||
const best = annBannerBest.get(key)
|
||||
if (!best || (r.epoch ?? 0) > (best.epoch ?? 0)) {
|
||||
if (best) staleIds.push(best.id)
|
||||
annBannerBest.set(key, r)
|
||||
} else {
|
||||
staleIds.push(r.id)
|
||||
}
|
||||
}
|
||||
if (staleIds.length > 0) {
|
||||
console.log(`Skipping ${staleIds.length} stale announcement_banner row(s)`)
|
||||
await supabase
|
||||
.from('scheduled_notifications')
|
||||
.update({ processed: true, processed_at: new Date().toISOString() })
|
||||
.in('id', staleIds)
|
||||
}
|
||||
const staleSet = new Set(staleIds)
|
||||
|
||||
for (const r of rows.filter((r: any) => !staleSet.has(r.id))) {
|
||||
try {
|
||||
const scheduleId = r.schedule_id
|
||||
const userId = r.user_id
|
||||
const notifyType = r.notify_type
|
||||
const rowId = r.id
|
||||
|
||||
// Build a unique ID that accounts for all reference columns + epoch
|
||||
const idSource = `${scheduleId || ''}-${r.task_id || ''}-${r.it_service_request_id || ''}-${r.pass_slip_id || ''}-${userId}-${notifyType}-${r.epoch || 0}`
|
||||
// Build a unique ID that accounts for all reference columns + epoch.
|
||||
// announcement_id is included so that concurrent banner announcements
|
||||
// targeting the same user+epoch get distinct notificationIds — without
|
||||
// it, try_mark_notification_pushed would silently drop the second one.
|
||||
const idSource = `${scheduleId || ''}-${r.task_id || ''}-${r.it_service_request_id || ''}-${r.pass_slip_id || ''}-${r.announcement_id || ''}-${userId}-${notifyType}-${r.epoch || 0}`
|
||||
const notificationId = await uuidFromName(idSource)
|
||||
|
||||
// Idempotency is handled by send_fcm via try_mark_notification_pushed.
|
||||
@@ -91,6 +122,7 @@ async function processBatch() {
|
||||
if (r.task_id) data.task_id = r.task_id
|
||||
if (r.it_service_request_id) data.it_service_request_id = r.it_service_request_id
|
||||
if (r.pass_slip_id) data.pass_slip_id = r.pass_slip_id
|
||||
if (r.announcement_id) data.announcement_id = r.announcement_id
|
||||
|
||||
switch (notifyType) {
|
||||
case 'start_15':
|
||||
@@ -148,6 +180,23 @@ async function processBatch() {
|
||||
body = 'Your pass slip has exceeded the 1-hour limit. Please return and complete it immediately.'
|
||||
data.navigate_to = '/attendance'
|
||||
break
|
||||
case 'announcement_banner': {
|
||||
const { data: ann } = await supabase
|
||||
.from('announcements')
|
||||
.select('title')
|
||||
.eq('id', r.announcement_id)
|
||||
.single()
|
||||
const rawTitle = ann?.title ?? ''
|
||||
const displayTitle = rawTitle.length > 80
|
||||
? rawTitle.substring(0, 80) + '\u2026'
|
||||
: rawTitle
|
||||
title = 'Announcement Reminder'
|
||||
body = displayTitle
|
||||
? `"${displayTitle}" — Please tap to review this announcement.`
|
||||
: 'You have a pending announcement that requires your attention. Tap to view it.'
|
||||
data.navigate_to = '/announcements'
|
||||
break
|
||||
}
|
||||
default:
|
||||
title = 'Reminder'
|
||||
body = 'You have a pending notification.'
|
||||
|
||||
@@ -38,11 +38,17 @@ Deno.serve(async (req) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Optional Idempotency (if you pass notification_id inside the data payload)
|
||||
// Idempotency: if notification_id is provided, use try_mark_notification_pushed
|
||||
// to ensure at-most-once delivery even under concurrent edge-function invocations.
|
||||
if (payload.data && payload.data.notification_id) {
|
||||
const { data: markData, error: markErr } = await supabase
|
||||
.rpc('try_mark_notification_pushed', { p_notification_id: payload.data.notification_id })
|
||||
|
||||
|
||||
if (markErr) {
|
||||
console.error('try_mark_notification_pushed RPC error, skipping to be safe:', markErr)
|
||||
return new Response('Idempotency check failed', { status: 200, headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (markData === false) {
|
||||
console.log('Notification already pushed, skipping:', payload.data.notification_id)
|
||||
return new Response('Already pushed', { status: 200, headers: corsHeaders })
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
-- 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;
|
||||
$$;
|
||||
@@ -0,0 +1,84 @@
|
||||
-- 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;
|
||||
$$;
|
||||
@@ -353,6 +353,10 @@ $$;
|
||||
-- ============================================================================
|
||||
-- MASTER DISPATCHER
|
||||
-- ============================================================================
|
||||
-- NOTE: This file has an underscore at position 9 of its filename, which makes
|
||||
-- it sort AFTER all 20260322NNNNNN_* files (underscore ASCII 95 > digits 48-57).
|
||||
-- It therefore runs LAST among all 20260322 migrations and must include every
|
||||
-- enqueue function defined by those earlier migrations.
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_all_notifications()
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
@@ -364,6 +368,8 @@ BEGIN
|
||||
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
|
||||
PERFORM public.enqueue_announcement_banner_notifications(); -- added in 20260322210000
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Migration: Definitive enqueue_all_notifications() dispatcher
|
||||
--
|
||||
-- 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)
|
||||
--
|
||||
-- This migration (20260323000000) sorts AFTER all 20260322* files and
|
||||
-- redefines enqueue_all_notifications() as the single authoritative definition
|
||||
-- that includes all 10 notification types.
|
||||
|
||||
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();
|
||||
PERFORM public.enqueue_announcement_banner_notifications();
|
||||
END;
|
||||
$$;
|
||||
Reference in New Issue
Block a user