From e0dd3f636896f439d9b9e65f4caa27c0fc66e206 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Mon, 28 Sep 2026 13:31:26 +0800 Subject: [PATCH] feat(db): day sheet RPCs, auto-submit cron, digest queue Add submit_day_sheet, disapprove_day_sheet, justify_day_sheet, approve_day_sheets (all SECURITY DEFINER, grant to authenticated) and auto_submit_day_sheets (revoked from all client roles). auto_submit_day_sheets: 1. Reopens approved yesterday sheets for programmers whose timer was still running at end of day (amended event). 2. Auto-promotes stale draft sheets to pending (auto_submitted event). 3. Inserts day_sheet_digest notifications + scheduled_notifications (epoch=YYYYMMDD, ON CONFLICT DO NOTHING) for every admin. Extends chk_at_least_one_ref on scheduled_notifications to allow notify_type = 'day_sheet_digest' rows with no FK reference. Registers pg_cron job 'programmer_day_sheet_autosubmit' at 16:05 UTC (00:05 Asia/Manila) in a DO $$ EXCEPTION block. Co-Authored-By: claude-flow --- ...260927150100_programmer_day_sheet_rpcs.sql | 482 ++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 supabase/migrations/20260927150100_programmer_day_sheet_rpcs.sql diff --git a/supabase/migrations/20260927150100_programmer_day_sheet_rpcs.sql b/supabase/migrations/20260927150100_programmer_day_sheet_rpcs.sql new file mode 100644 index 00000000..9a8c423c --- /dev/null +++ b/supabase/migrations/20260927150100_programmer_day_sheet_rpcs.sql @@ -0,0 +1,482 @@ +-- Day sheet RPCs, auto-submit cron, and digest queue. +-- +-- Depends on: +-- 20260927150000_add_programmer_day_sheets.sql (programmer_day_sheets, +-- programmer_day_sheet_events, ensure_day_sheet, manila_today) +-- +-- Provides (SECURITY DEFINER, set search_path = public): +-- submit_day_sheet(p_date date) returns jsonb +-- disapprove_day_sheet(p_sheet uuid, p_remarks text, +-- p_flagged_task_ids uuid[]) returns jsonb +-- justify_day_sheet(p_sheet uuid, p_body text) returns jsonb +-- approve_day_sheets(p_items jsonb) returns jsonb +-- auto_submit_day_sheets() returns void +-- (revoked from public/anon/authenticated) + +-- --------------------------------------------------------------------------- +-- 1. submit_day_sheet +-- --------------------------------------------------------------------------- +create or replace function public.submit_day_sheet(p_date date) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_caller_id uuid := auth.uid(); + v_caller_role text; + v_sheet programmer_day_sheets%rowtype; + v_admin record; + v_notify_ids uuid[] := '{}'; + v_prog_name text; +begin + -- Caller must be a programmer + select role into v_caller_role + from profiles + where id = v_caller_id; + + if v_caller_role is distinct from 'programmer' then + raise exception 'not_authorized'; + end if; + + -- Future dates are not allowed + if p_date > manila_today() then + raise exception 'future_date'; + end if; + + -- Upsert draft row (idempotent bootstrap) + insert into programmer_day_sheets (programmer_id, work_date, status) + values (v_caller_id, p_date, 'draft') + on conflict (programmer_id, work_date) do nothing; + + -- Lock the sheet row before status check + select * into v_sheet + from programmer_day_sheets + where programmer_id = v_caller_id + and work_date = p_date + for update; + + if v_sheet.status <> 'draft' then + raise exception 'already_submitted'; + end if; + + -- Submit + update programmer_day_sheets + set status = 'pending', + submitted_at = now(), + auto_submitted = false + where id = v_sheet.id; + + insert into programmer_day_sheet_events (sheet_id, actor_id, kind) + values (v_sheet.id, v_caller_id, 'submitted'); + + -- Notify every admin + for v_admin in + select id from profiles where role = 'admin' + loop + insert into notifications (user_id, actor_id, type, day_sheet_id) + values (v_admin.id, v_caller_id, 'day_sheet_submitted', v_sheet.id); + + v_notify_ids := v_notify_ids || v_admin.id; + end loop; + + select full_name into v_prog_name + from profiles + where id = v_caller_id; + + return jsonb_build_object( + 'sheet_id', v_sheet.id, + 'work_date', v_sheet.work_date, + 'programmer_id', v_caller_id, + 'programmer_name', v_prog_name, + 'notify_user_ids', v_notify_ids + ); +end; +$$; + +grant execute on function public.submit_day_sheet(date) to authenticated; + +-- --------------------------------------------------------------------------- +-- 2. disapprove_day_sheet +-- --------------------------------------------------------------------------- +create or replace function public.disapprove_day_sheet( + p_sheet uuid, + p_remarks text, + p_flagged_task_ids uuid[] default '{}' +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_caller_id uuid := auth.uid(); + v_caller_role text; + v_sheet programmer_day_sheets%rowtype; + v_prog_name text; +begin + -- Caller must be admin + select role into v_caller_role + from profiles + where id = v_caller_id; + + if v_caller_role is distinct from 'admin' then + raise exception 'not_authorized'; + end if; + + if btrim(p_remarks) = '' then + raise exception 'remarks_required'; + end if; + + -- Lock the sheet row before status check + select * into v_sheet + from programmer_day_sheets + where id = p_sheet + for update; + + if v_sheet.status <> 'pending' then + raise exception 'already_reviewed'; + end if; + + update programmer_day_sheets + set status = 'disapproved', + reviewed_by = v_caller_id, + reviewed_at = now() + where id = p_sheet; + + insert into programmer_day_sheet_events + (sheet_id, actor_id, kind, body, flagged_task_ids) + values + (p_sheet, v_caller_id, 'disapproved', p_remarks, p_flagged_task_ids); + + insert into notifications (user_id, actor_id, type, day_sheet_id) + values (v_sheet.programmer_id, v_caller_id, 'day_sheet_disapproved', p_sheet); + + select full_name into v_prog_name + from profiles + where id = v_sheet.programmer_id; + + return jsonb_build_object( + 'sheet_id', p_sheet, + 'work_date', v_sheet.work_date, + 'programmer_id', v_sheet.programmer_id, + 'programmer_name', v_prog_name, + 'notify_user_ids', array[v_sheet.programmer_id] + ); +end; +$$; + +grant execute on function public.disapprove_day_sheet(uuid, text, uuid[]) to authenticated; + +-- --------------------------------------------------------------------------- +-- 3. justify_day_sheet +-- --------------------------------------------------------------------------- +create or replace function public.justify_day_sheet( + p_sheet uuid, + p_body text +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_caller_id uuid := auth.uid(); + v_sheet programmer_day_sheets%rowtype; + v_notify_ids uuid[] := '{}'; + v_prog_name text; +begin + if btrim(p_body) = '' then + raise exception 'body_required'; + end if; + + -- Lock the sheet row before status check + select * into v_sheet + from programmer_day_sheets + where id = p_sheet + for update; + + -- Caller must be the sheet owner + if v_sheet.programmer_id is distinct from v_caller_id then + raise exception 'not_authorized'; + end if; + + if v_sheet.status <> 'disapproved' then + raise exception 'already_reviewed'; + end if; + + update programmer_day_sheets + set status = 'pending', + resubmissions = resubmissions + 1, + submitted_at = now() + where id = p_sheet; + + insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body) + values (p_sheet, v_caller_id, 'justified', p_body); + + -- Notify the previous reviewer if set + if v_sheet.reviewed_by is not null then + insert into notifications (user_id, actor_id, type, day_sheet_id) + values (v_sheet.reviewed_by, v_caller_id, 'day_sheet_justified', p_sheet); + + v_notify_ids := array[v_sheet.reviewed_by]; + end if; + + select full_name into v_prog_name + from profiles + where id = v_caller_id; + + return jsonb_build_object( + 'sheet_id', p_sheet, + 'work_date', v_sheet.work_date, + 'programmer_id', v_caller_id, + 'programmer_name', v_prog_name, + 'notify_user_ids', v_notify_ids + ); +end; +$$; + +grant execute on function public.justify_day_sheet(uuid, text) to authenticated; + +-- --------------------------------------------------------------------------- +-- 4. approve_day_sheets +-- --------------------------------------------------------------------------- +create or replace function public.approve_day_sheets(p_items jsonb) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_caller_id uuid := auth.uid(); + v_caller_role text; + v_item jsonb; + v_sheet_id uuid; + v_snapshot jsonb; + v_sheet programmer_day_sheets%rowtype; + v_approved jsonb[] := '{}'; + v_skipped uuid[] := '{}'; + v_prog_name text; +begin + -- Caller must be admin + select role into v_caller_role + from profiles + where id = v_caller_id; + + if v_caller_role is distinct from 'admin' then + raise exception 'not_authorized'; + end if; + + for v_item in select * from jsonb_array_elements(p_items) + loop + v_sheet_id := (v_item->>'sheet_id')::uuid; + v_snapshot := v_item->'snapshot'; + + -- Validate snapshot: must be an object with a 'rows' array + if jsonb_typeof(v_snapshot) <> 'object' + or jsonb_typeof(v_snapshot->'rows') <> 'array' then + raise exception 'invalid_snapshot'; + end if; + + -- Lock the sheet row before status check + select * into v_sheet + from programmer_day_sheets + where id = v_sheet_id + for update; + + if v_sheet.status not in ('pending', 'disapproved') then + v_skipped := v_skipped || v_sheet_id; + continue; + end if; + + update programmer_day_sheets + set status = 'approved', + reviewed_by = v_caller_id, + reviewed_at = now(), + approved_snapshot = v_snapshot + where id = v_sheet_id; + + insert into programmer_day_sheet_events (sheet_id, actor_id, kind) + values (v_sheet_id, v_caller_id, 'approved'); + + insert into notifications (user_id, actor_id, type, day_sheet_id) + values (v_sheet.programmer_id, v_caller_id, 'day_sheet_approved', v_sheet_id); + + select full_name into v_prog_name + from profiles + where id = v_sheet.programmer_id; + + v_approved := v_approved || jsonb_build_object( + 'sheet_id', v_sheet_id, + 'programmer_id', v_sheet.programmer_id, + 'work_date', v_sheet.work_date + ); + end loop; + + return jsonb_build_object( + 'approved', to_jsonb(v_approved), + 'skipped_ids', to_jsonb(v_skipped) + ); +end; +$$; + +grant execute on function public.approve_day_sheets(jsonb) to authenticated; + +-- --------------------------------------------------------------------------- +-- 5. auto_submit_day_sheets +-- --------------------------------------------------------------------------- +create or replace function public.auto_submit_day_sheets() +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + v_rec record; + v_sheet_id uuid; + v_sheet_status text; + v_any_pending boolean := false; + v_admin record; +begin + -- ── Step 1: tasks whose timer is still running at end of day ─────────────── + -- For each in_progress task where the latest started/paused/resumed event is + -- not 'paused', ensure a day sheet exists for the programmer assignee on + -- yesterday's date. If that sheet is approved, reopen it to pending. + for v_rec in + select distinct pt.assignee_id + from programmer_tasks pt + where pt.status = 'in_progress' + and pt.assignee_id is not null + and exists ( + select 1 from profiles p + where p.id = pt.assignee_id + and p.role = 'programmer' + ) + and exists ( + select 1 from programmer_task_activity_logs pal + where pal.task_id = pt.id + and pal.action_type in ('started', 'paused', 'resumed') + ) + and ( + select pal2.action_type + from programmer_task_activity_logs pal2 + where pal2.task_id = pt.id + and pal2.action_type in ('started', 'paused', 'resumed') + order by pal2.created_at desc + limit 1 + ) <> 'paused' + loop + v_sheet_id := ensure_day_sheet(v_rec.assignee_id, manila_today() - 1); + + if v_sheet_id is null then + continue; + end if; + + -- Lock and check the sheet status + select status into v_sheet_status + from programmer_day_sheets + where id = v_sheet_id + for update; + + if v_sheet_status = 'approved' then + update programmer_day_sheets + set status = 'pending', + updated_at = now() + where id = v_sheet_id; + + insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body) + values (v_sheet_id, null, 'amended', + 'Timer was still running at the end of the day.'); + + v_any_pending := true; + end if; + end loop; + + -- ── Step 2: auto-submit stale draft sheets ───────────────────────────────── + -- Every draft sheet with work_date < today is promoted to pending. + for v_rec in + select id + from programmer_day_sheets + where status = 'draft' + and work_date < manila_today() + for update + loop + update programmer_day_sheets + set status = 'pending', + submitted_at = now(), + auto_submitted = true + where id = v_rec.id; + + insert into programmer_day_sheet_events (sheet_id, actor_id, kind) + values (v_rec.id, null, 'auto_submitted'); + + v_any_pending := true; + end loop; + + -- ── Step 3: digest notification for admins ───────────────────────────────── + -- If any sheet transitioned to pending, insert a notification and a + -- scheduled digest row (one per admin, idempotent via ON CONFLICT DO NOTHING). + if v_any_pending then + for v_admin in + select id from profiles where role = 'admin' + loop + insert into notifications (user_id, actor_id, type) + values (v_admin.id, null, 'day_sheet_digest'); + + insert into scheduled_notifications ( + user_id, + notify_type, + scheduled_for, + epoch + ) values ( + v_admin.id, + 'day_sheet_digest', + (manila_today() + time '08:00') at time zone 'Asia/Manila', + to_char(manila_today(), 'YYYYMMDD')::int + ) on conflict do nothing; + end loop; + end if; +end; +$$; + +-- auto_submit_day_sheets is a cron-only function; revoke from all client roles +revoke execute on function public.auto_submit_day_sheets() from public; +revoke execute on function public.auto_submit_day_sheets() from anon; +revoke execute on function public.auto_submit_day_sheets() from authenticated; + +-- --------------------------------------------------------------------------- +-- 6. Extend chk_at_least_one_ref to allow day_sheet_digest rows +-- (which have no FK reference, identified solely by notify_type) +-- --------------------------------------------------------------------------- +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 + or notify_type = 'day_sheet_digest' + ); + +-- --------------------------------------------------------------------------- +-- 7. pg_cron: auto-submit at 00:05 Manila time (16:05 UTC) +-- --------------------------------------------------------------------------- +do $$ +begin + begin + perform cron.unschedule('programmer_day_sheet_autosubmit'); + exception when others then null; + end; + + perform cron.schedule( + 'programmer_day_sheet_autosubmit', + '5 16 * * *', + 'SELECT public.auto_submit_day_sheets();' + ); + +exception when others then + raise notice 'pg_cron scheduling failed: %. Set up cron jobs manually or use external cron.', sqlerrm; +end $$;