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 <ruv@ruv.net>
This commit is contained in:
2026-09-28 13:31:26 +08:00
parent e2ff0140ef
commit e0dd3f6368
@@ -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 $$;