-- 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 $$;