Added My Schedule tab in attendance screen

Allow 1 Day, Whole Week and Date Range swapping
This commit is contained in:
2026-03-22 11:52:25 +08:00
parent ba155885c0
commit 049ab2c794
17 changed files with 2515 additions and 305 deletions
@@ -143,6 +143,11 @@ async function processBatch() {
body = 'Your pass slip expires in 15 minutes. Please return and complete it.'
data.navigate_to = '/attendance'
break
case 'pass_slip_expired_15':
title = 'Pass slip OVERDUE'
body = 'Your pass slip has exceeded the 1-hour limit. Please return and complete it immediately.'
data.navigate_to = '/attendance'
break
default:
title = 'Reminder'
body = 'You have a pending notification.'
@@ -0,0 +1,78 @@
-- Migration: Pass slip requested_start column + expired pass slip notifications
--
-- 1. Add optional requested_start column to pass_slips
-- 2. Create enqueue_pass_slip_expired_notifications() for recurring reminders
-- every 15 minutes after a pass slip exceeds its 1-hour limit
-- 3. Update enqueue_all_notifications() master dispatcher
-- ============================================================================
-- 1. SCHEMA: Add requested_start column
-- ============================================================================
ALTER TABLE public.pass_slips
ADD COLUMN IF NOT EXISTS requested_start timestamptz;
-- ============================================================================
-- 2. ENQUEUE FUNCTION: Expired pass slip reminders (every 15 min after 1 hour)
-- ============================================================================
-- Follows the end_hourly pattern: uses epoch for unique rows, checks latest
-- scheduled notification to enforce a minimum gap between reminders.
-- Caps at 24 hours to avoid processing ancient slips.
CREATE OR REPLACE FUNCTION public.enqueue_pass_slip_expired_notifications()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
rec RECORD;
v_intervals_since_expiry int;
v_latest_expired timestamptz;
BEGIN
FOR rec IN
SELECT ps.id AS pass_slip_id, ps.user_id, ps.slip_start
FROM public.pass_slips ps
WHERE ps.status = 'approved'
AND ps.slip_end IS NULL
AND ps.slip_start IS NOT NULL
-- Expired: past the 1-hour mark
AND ps.slip_start + interval '1 hour' <= now()
-- Cap at 24 hours to avoid processing ancient slips
AND ps.slip_start + interval '24 hours' > now()
LOOP
-- Calculate how many 15-min intervals since expiry (for unique epoch)
v_intervals_since_expiry := GREATEST(1,
EXTRACT(EPOCH FROM (now() - (rec.slip_start + interval '1 hour')))::int / 900
);
-- Check latest expired notification for this pass slip
SELECT MAX(scheduled_for) INTO v_latest_expired
FROM public.scheduled_notifications
WHERE pass_slip_id = rec.pass_slip_id
AND user_id = rec.user_id
AND notify_type = 'pass_slip_expired_15';
-- Only enqueue if no prior expired reminder, or last one was >14 min ago
IF v_latest_expired IS NULL OR v_latest_expired < now() - interval '14 minutes' THEN
INSERT INTO public.scheduled_notifications
(pass_slip_id, user_id, notify_type, scheduled_for, epoch)
VALUES
(rec.pass_slip_id, rec.user_id, 'pass_slip_expired_15', now(), v_intervals_since_expiry)
ON CONFLICT DO NOTHING;
END IF;
END LOOP;
END;
$$;
-- ============================================================================
-- 3. MASTER DISPATCHER: Add new function
-- ============================================================================
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();
END;
$$;
@@ -0,0 +1,99 @@
-- Add swap_request_id to duty_schedules so accepted swaps are marked on the affected schedules
ALTER TABLE public.duty_schedules
ADD COLUMN IF NOT EXISTS swap_request_id uuid REFERENCES public.swap_requests(id);
-- Update respond_shift_swap to stamp swap_request_id on both swapped schedules
CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
v_swap RECORD;
BEGIN
SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'swap request not found';
END IF;
IF p_action NOT IN ('accepted','rejected','admin_review') THEN
RAISE EXCEPTION 'invalid action';
END IF;
IF p_action = 'accepted' THEN
-- only recipient or admin/dispatcher can accept
IF NOT (
v_swap.recipient_id = auth.uid()
OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher'))
) THEN
RAISE EXCEPTION 'permission denied';
END IF;
-- ensure both shifts are still owned by the expected users before swapping
IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id) THEN
RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap';
END IF;
IF v_swap.target_shift_id IS NULL THEN
RAISE EXCEPTION 'target shift missing';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id) THEN
RAISE EXCEPTION 'target shift ownership changed, cannot accept swap';
END IF;
-- perform the swap (atomic within function) and stamp swap_request_id
UPDATE public.duty_schedules
SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id
WHERE id = v_swap.shift_id;
UPDATE public.duty_schedules
SET user_id = v_swap.requester_id, swap_request_id = p_swap_id
WHERE id = v_swap.target_shift_id;
UPDATE public.swap_requests
SET status = 'accepted', updated_at = now()
WHERE id = p_swap_id;
-- record approver/participant
INSERT INTO public.swap_request_participants(swap_request_id, user_id, role)
VALUES (p_swap_id, auth.uid(), 'approver')
ON CONFLICT DO NOTHING;
-- notify requester about approval
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
ELSIF p_action = 'rejected' THEN
-- only recipient or admin/dispatcher can reject
IF NOT (
v_swap.recipient_id = auth.uid()
OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher'))
) THEN
RAISE EXCEPTION 'permission denied';
END IF;
UPDATE public.swap_requests
SET status = 'rejected', updated_at = now()
WHERE id = p_swap_id;
INSERT INTO public.swap_request_participants(swap_request_id, user_id, role)
VALUES (p_swap_id, auth.uid(), 'approver')
ON CONFLICT DO NOTHING;
-- notify requester about rejection
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
ELSE -- admin_review
-- only requester may escalate for admin review
IF NOT (v_swap.requester_id = auth.uid()) THEN
RAISE EXCEPTION 'permission denied';
END IF;
UPDATE public.swap_requests
SET status = 'admin_review', updated_at = now()
WHERE id = p_swap_id;
-- notify requester about escalation
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
END IF;
END;
$$;
@@ -0,0 +1,117 @@
-- Fix respond_shift_swap:
-- 1. Guard against double-processing (idempotent — return early if already terminal)
-- 2. SECURITY DEFINER to bypass RLS for cross-user duty_schedule ownership checks
-- (the function still enforces caller identity via auth.uid())
CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_swap RECORD;
BEGIN
SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'swap request not found';
END IF;
IF p_action NOT IN ('accepted','rejected','admin_review') THEN
RAISE EXCEPTION 'invalid action';
END IF;
-- Idempotency guard: already in terminal state → nothing more to do.
-- This prevents spurious "ownership changed" errors when a stale UI retries
-- an acceptance that was already processed on another device or by an admin.
IF v_swap.status IN ('accepted', 'rejected') THEN
RETURN;
END IF;
IF p_action = 'accepted' THEN
-- only recipient or admin/dispatcher can accept
IF NOT (
v_swap.recipient_id = auth.uid()
OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher'))
) THEN
RAISE EXCEPTION 'permission denied';
END IF;
-- ensure both shifts are still owned by the expected users before swapping
IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id) THEN
RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap';
END IF;
IF v_swap.target_shift_id IS NULL THEN
RAISE EXCEPTION 'target shift missing';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id) THEN
RAISE EXCEPTION 'target shift ownership changed, cannot accept swap';
END IF;
-- perform the swap (atomic within function) and stamp swap_request_id
UPDATE public.duty_schedules
SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id
WHERE id = v_swap.shift_id;
UPDATE public.duty_schedules
SET user_id = v_swap.requester_id, swap_request_id = p_swap_id
WHERE id = v_swap.target_shift_id;
UPDATE public.swap_requests
SET status = 'accepted', updated_at = now()
WHERE id = p_swap_id;
INSERT INTO public.swap_request_participants(swap_request_id, user_id, role)
VALUES (p_swap_id, auth.uid(), 'approver')
ON CONFLICT DO NOTHING;
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
ELSIF p_action = 'rejected' THEN
-- only recipient or admin/dispatcher can reject
IF NOT (
v_swap.recipient_id = auth.uid()
OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher'))
) THEN
RAISE EXCEPTION 'permission denied';
END IF;
UPDATE public.swap_requests
SET status = 'rejected', updated_at = now()
WHERE id = p_swap_id;
INSERT INTO public.swap_request_participants(swap_request_id, user_id, role)
VALUES (p_swap_id, auth.uid(), 'approver')
ON CONFLICT DO NOTHING;
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
ELSE -- admin_review
-- only requester may escalate for admin review
IF NOT (v_swap.requester_id = auth.uid()) THEN
RAISE EXCEPTION 'permission denied';
END IF;
UPDATE public.swap_requests
SET status = 'admin_review', updated_at = now()
WHERE id = p_swap_id;
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
END IF;
END;
$$;
-- Re-grant EXECUTE to authenticated users.
-- SECURITY DEFINER functions drop existing grants on replace, so this must
-- be explicit.
GRANT EXECUTE ON FUNCTION public.respond_shift_swap(uuid, text) TO authenticated;
-- Enable full replica identity so UPDATE events include all columns,
-- and add to the realtime publication so .stream() receives changes.
-- Without this, the Flutter client never sees status transitions (e.g.
-- pending → accepted) via Supabase Realtime — only the initial REST fetch.
ALTER TABLE public.swap_requests REPLICA IDENTITY FULL;
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.swap_requests;
EXCEPTION WHEN duplicate_object THEN
NULL; -- already present
END $$;
@@ -0,0 +1,120 @@
-- When a swap is accepted, any OTHER pending/admin_review swap requests that
-- reference the same schedules become invalid (ownership changed). Rather than
-- letting them linger as "pending" until someone taps them and gets a confusing
-- ownership error, we automatically reject them in the same transaction.
--
-- This fixes the "PM shift still showing after ON_CALL swap accepted" scenario:
-- Swap A: User X's ON_CALL ↔ User Y's PM → accepted
-- Swap B: User X's ON_CALL ↔ User Z's PM → auto-rejected here (stale)
CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_swap RECORD;
BEGIN
SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'swap request not found';
END IF;
IF p_action NOT IN ('accepted','rejected','admin_review') THEN
RAISE EXCEPTION 'invalid action';
END IF;
-- Idempotency guard: already in terminal state → nothing more to do.
IF v_swap.status IN ('accepted', 'rejected') THEN
RETURN;
END IF;
IF p_action = 'accepted' THEN
-- only recipient or admin/dispatcher can accept
IF NOT (
v_swap.recipient_id = auth.uid()
OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher'))
) THEN
RAISE EXCEPTION 'permission denied';
END IF;
-- ensure both shifts are still owned by the expected users before swapping
IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id) THEN
RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap';
END IF;
IF v_swap.target_shift_id IS NULL THEN
RAISE EXCEPTION 'target shift missing';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id) THEN
RAISE EXCEPTION 'target shift ownership changed, cannot accept swap';
END IF;
-- perform the swap (atomic within function) and stamp swap_request_id
UPDATE public.duty_schedules
SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id
WHERE id = v_swap.shift_id;
UPDATE public.duty_schedules
SET user_id = v_swap.requester_id, swap_request_id = p_swap_id
WHERE id = v_swap.target_shift_id;
UPDATE public.swap_requests
SET status = 'accepted', updated_at = now()
WHERE id = p_swap_id;
-- Auto-reject all OTHER pending/admin_review swap requests that reference
-- either of the now-swapped schedules. These are stale — the shift
-- ownerships changed, so they can never be fulfilled.
UPDATE public.swap_requests
SET status = 'rejected', updated_at = now()
WHERE id <> p_swap_id
AND status IN ('pending', 'admin_review')
AND (
shift_id = v_swap.shift_id
OR shift_id = v_swap.target_shift_id
OR target_shift_id = v_swap.shift_id
OR target_shift_id = v_swap.target_shift_id
);
INSERT INTO public.swap_request_participants(swap_request_id, user_id, role)
VALUES (p_swap_id, auth.uid(), 'approver')
ON CONFLICT DO NOTHING;
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
ELSIF p_action = 'rejected' THEN
-- only recipient or admin/dispatcher can reject
IF NOT (
v_swap.recipient_id = auth.uid()
OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher'))
) THEN
RAISE EXCEPTION 'permission denied';
END IF;
UPDATE public.swap_requests
SET status = 'rejected', updated_at = now()
WHERE id = p_swap_id;
INSERT INTO public.swap_request_participants(swap_request_id, user_id, role)
VALUES (p_swap_id, auth.uid(), 'approver')
ON CONFLICT DO NOTHING;
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
ELSE -- admin_review
-- only requester may escalate for admin review
IF NOT (v_swap.requester_id = auth.uid()) THEN
RAISE EXCEPTION 'permission denied';
END IF;
UPDATE public.swap_requests
SET status = 'admin_review', updated_at = now()
WHERE id = p_swap_id;
INSERT INTO public.notifications(user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
END IF;
END;
$$;
-- Re-grant EXECUTE (SECURITY DEFINER drops grants on replace).
GRANT EXECUTE ON FUNCTION public.respond_shift_swap(uuid, text) TO authenticated;
@@ -0,0 +1,227 @@
-- Business rule: PM Duty always comes with ON_CALL (consecutive overnight
-- shift, same person, same calendar day). Accepting a swap that involves a
-- PM schedule must also atomically transfer the companion on_call duty
-- schedule to the new PM holder so the pairing stays intact.
--
-- Handles BOTH swap directions:
-- A) target_shift_id is PM → requester receives PM; transfer recipient's
-- companion ON_CALL to requester.
-- B) shift_id is PM → recipient receives PM; transfer requester's
-- companion ON_CALL to recipient.
--
-- Example (direction A — Normal user initiates):
-- Before: User A owns Normal (08:0017:00), User B owns PM (15:0023:00)
-- and ON_CALL (23:0007:00) on the same calendar day.
-- After accept: User A owns PM + ON_CALL, User B owns Normal.
--
-- Example (direction B — PM user initiates):
-- Before: User B owns PM + ON_CALL, User A owns Normal.
-- After accept: same result — User A owns PM + ON_CALL, User B owns Normal.
--
-- Supersedes 20260322180000_auto_reject_orphaned_swaps.sql.
CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_swap RECORD;
v_target_type text;
v_target_date date;
v_requester_type text;
v_requester_date date;
v_companion_id uuid;
BEGIN
SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'swap request not found';
END IF;
IF p_action NOT IN ('accepted', 'rejected', 'admin_review') THEN
RAISE EXCEPTION 'invalid action';
END IF;
-- Idempotency guard: already terminal → nothing to do.
IF v_swap.status IN ('accepted', 'rejected') THEN
RETURN;
END IF;
-- ── ACCEPTED ──────────────────────────────────────────────────────────────
IF p_action = 'accepted' THEN
-- Permission: recipient or admin/dispatcher
IF NOT (
v_swap.recipient_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND p.role IN ('admin', 'dispatcher')
)
) THEN
RAISE EXCEPTION 'permission denied';
END IF;
-- Ownership validation before swapping
IF NOT EXISTS (
SELECT 1 FROM public.duty_schedules
WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id
) THEN
RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap';
END IF;
IF v_swap.target_shift_id IS NULL THEN
RAISE EXCEPTION 'target shift missing';
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.duty_schedules
WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id
) THEN
RAISE EXCEPTION 'target shift ownership changed, cannot accept swap';
END IF;
-- Read both schedules' types and dates BEFORE the swap so we can decide
-- companion direction independently of ownership changes.
SELECT shift_type,
DATE(start_time AT TIME ZONE 'Asia/Manila')
INTO v_target_type, v_target_date
FROM public.duty_schedules
WHERE id = v_swap.target_shift_id;
SELECT shift_type,
DATE(start_time AT TIME ZONE 'Asia/Manila')
INTO v_requester_type, v_requester_date
FROM public.duty_schedules
WHERE id = v_swap.shift_id;
-- Primary swap: transfer both schedules atomically
UPDATE public.duty_schedules
SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id
WHERE id = v_swap.shift_id; -- requester's schedule → recipient
UPDATE public.duty_schedules
SET user_id = v_swap.requester_id, swap_request_id = p_swap_id
WHERE id = v_swap.target_shift_id; -- recipient's schedule → requester
-- ── Companion ON_CALL transfer ────────────────────────────────────────
-- Direction A: target is PM → requester is the new PM holder.
-- Find companion ON_CALL previously owned by the RECIPIENT and give it
-- to the REQUESTER.
IF v_target_type = 'pm' THEN
SELECT id INTO v_companion_id
FROM public.duty_schedules
WHERE user_id = v_swap.recipient_id
AND shift_type = 'on_call'
AND DATE(start_time AT TIME ZONE 'Asia/Manila') = v_target_date
ORDER BY start_time
LIMIT 1;
IF v_companion_id IS NOT NULL THEN
UPDATE public.duty_schedules
SET user_id = v_swap.requester_id, swap_request_id = p_swap_id
WHERE id = v_companion_id;
UPDATE public.swap_requests
SET status = 'rejected', updated_at = now()
WHERE id <> p_swap_id
AND status IN ('pending', 'admin_review')
AND (
shift_id = v_companion_id
OR target_shift_id = v_companion_id
);
END IF;
-- Direction B: requester's schedule is PM → recipient is the new PM holder.
-- Find companion ON_CALL previously owned by the REQUESTER and give it
-- to the RECIPIENT.
ELSIF v_requester_type = 'pm' THEN
SELECT id INTO v_companion_id
FROM public.duty_schedules
WHERE user_id = v_swap.requester_id
AND shift_type = 'on_call'
AND DATE(start_time AT TIME ZONE 'Asia/Manila') = v_requester_date
ORDER BY start_time
LIMIT 1;
IF v_companion_id IS NOT NULL THEN
UPDATE public.duty_schedules
SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id
WHERE id = v_companion_id;
UPDATE public.swap_requests
SET status = 'rejected', updated_at = now()
WHERE id <> p_swap_id
AND status IN ('pending', 'admin_review')
AND (
shift_id = v_companion_id
OR target_shift_id = v_companion_id
);
END IF;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Accept the swap request
UPDATE public.swap_requests
SET status = 'accepted', updated_at = now()
WHERE id = p_swap_id;
-- Auto-reject all other pending swaps referencing either primary schedule
UPDATE public.swap_requests
SET status = 'rejected', updated_at = now()
WHERE id <> p_swap_id
AND status IN ('pending', 'admin_review')
AND (
shift_id = v_swap.shift_id
OR shift_id = v_swap.target_shift_id
OR target_shift_id = v_swap.shift_id
OR target_shift_id = v_swap.target_shift_id
);
INSERT INTO public.swap_request_participants (swap_request_id, user_id, role)
VALUES (p_swap_id, auth.uid(), 'approver')
ON CONFLICT DO NOTHING;
INSERT INTO public.notifications (user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
-- ── REJECTED ──────────────────────────────────────────────────────────────
ELSIF p_action = 'rejected' THEN
IF NOT (
v_swap.recipient_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND p.role IN ('admin', 'dispatcher')
)
) THEN
RAISE EXCEPTION 'permission denied';
END IF;
UPDATE public.swap_requests
SET status = 'rejected', updated_at = now()
WHERE id = p_swap_id;
INSERT INTO public.swap_request_participants (swap_request_id, user_id, role)
VALUES (p_swap_id, auth.uid(), 'approver')
ON CONFLICT DO NOTHING;
INSERT INTO public.notifications (user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
-- ── ADMIN_REVIEW ──────────────────────────────────────────────────────────
ELSE
IF NOT (v_swap.requester_id = auth.uid()) THEN
RAISE EXCEPTION 'permission denied';
END IF;
UPDATE public.swap_requests
SET status = 'admin_review', updated_at = now()
WHERE id = p_swap_id;
INSERT INTO public.notifications (user_id, actor_id, type, created_at)
VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now());
END IF;
END;
$$;
-- Re-grant after SECURITY DEFINER replace
GRANT EXECUTE ON FUNCTION public.respond_shift_swap(uuid, text) TO authenticated;
@@ -0,0 +1,10 @@
-- Enable realtime updates for duty_schedules so that schedule ownership
-- changes (e.g. after a swap is accepted) propagate to all clients
-- immediately without waiting for the next poll cycle.
ALTER TABLE public.duty_schedules REPLICA IDENTITY FULL;
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.duty_schedules;
EXCEPTION WHEN duplicate_object THEN
NULL;
END $$;