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:
@@ -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 $$;
|
||||
Reference in New Issue
Block a user