feat(db): programmer day sheets schema, triggers, RLS

Adds migration 20260927150000_add_programmer_day_sheets.sql:
- manila_today() stable helper (Asia/Manila timezone)
- programmer_day_sheets table with draft/pending/disapproved/approved status,
  unique(programmer_id, work_date), updated_at trigger, RLS
- programmer_day_sheet_events table with kind/body checks, cascade FK, RLS
- work_date column on programmer_task_work_logs (add, backfill, default, not null)
- ensure_day_sheet() SECURITY DEFINER; revoked from public/anon/authenticated
- BEFORE INSERT trigger validate_work_log_date (work_date_locked error code)
- AFTER INSERT trigger on work_logs → ensure_day_sheet
- AFTER INSERT trigger on activity_logs → ensure_day_sheet for assignee
- RLS SELECT policies (own sheet or admin; events through parent sheet)
- notifications.day_sheet_id FK column + partial index
- Add day sheets tables to supabase_realtime publication

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-27 22:51:41 +08:00
parent e24083356f
commit e2ff0140ef
@@ -0,0 +1,311 @@
-- Programmer Day Sheets — daily approval workflow for programmer work logs.
-- Each programmer gets one sheet per calendar day (Manila timezone). Sheets
-- aggregate work-log entries and activity-log events, flow through
-- draft → pending → approved/disapproved state machine, and expose an
-- event trail (submitted, disapproved, justified, approved, amended).
--
-- Depends on:
-- 20260927120000_add_programmer_tasks.sql (programmer_tasks, programmer_task_activity_logs,
-- set_programmer_tasks_updated_at)
-- 20260927140000_add_programmer_projects_worklogs_comments.sql
-- (programmer_task_work_logs)
-- ---------------------------------------------------------------------------
-- 1. manila_today() — canonical Manila-timezone "today" used everywhere
-- ---------------------------------------------------------------------------
create or replace function manila_today()
returns date
language sql
stable
security definer
set search_path = public
as $$
select (now() at time zone 'Asia/Manila')::date;
$$;
-- ---------------------------------------------------------------------------
-- 2. programmer_day_sheets
-- ---------------------------------------------------------------------------
create table if not exists programmer_day_sheets (
id uuid primary key default gen_random_uuid(),
programmer_id uuid not null references profiles(id),
work_date date not null,
status text not null default 'draft'
check (status in ('draft','pending','disapproved','approved')),
submitted_at timestamptz,
auto_submitted bool not null default false,
reviewed_by uuid references profiles(id),
reviewed_at timestamptz,
resubmissions int not null default 0,
approved_snapshot jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (programmer_id, work_date)
);
create index if not exists idx_programmer_day_sheets_status_work_date
on programmer_day_sheets (status, work_date);
alter table programmer_day_sheets enable row level security;
-- updated_at trigger — reuse the existing function
drop trigger if exists trg_programmer_day_sheets_updated_at on programmer_day_sheets;
create trigger trg_programmer_day_sheets_updated_at
before update on programmer_day_sheets
for each row execute function set_programmer_tasks_updated_at();
-- ---------------------------------------------------------------------------
-- 3. programmer_day_sheet_events
-- ---------------------------------------------------------------------------
create table if not exists programmer_day_sheet_events (
id uuid primary key default gen_random_uuid(),
sheet_id uuid not null references programmer_day_sheets(id) on delete cascade,
actor_id uuid references profiles(id),
kind text not null
check (kind in ('submitted','auto_submitted','disapproved',
'justified','approved','amended')),
body text,
flagged_task_ids uuid[] not null default '{}',
created_at timestamptz not null default now(),
-- body must be non-empty for disapproval and justification events
check (kind not in ('disapproved','justified') or (body is not null and body <> ''))
);
alter table programmer_day_sheet_events enable row level security;
-- ---------------------------------------------------------------------------
-- 4. work_date column on programmer_task_work_logs
-- ---------------------------------------------------------------------------
alter table programmer_task_work_logs
add column if not exists work_date date;
-- Backfill: derive from created_at in Manila timezone
update programmer_task_work_logs
set work_date = (created_at at time zone 'Asia/Manila')::date
where work_date is null;
-- Now make it default + not null
alter table programmer_task_work_logs
alter column work_date set default manila_today();
alter table programmer_task_work_logs
alter column work_date set not null;
create index if not exists idx_programmer_task_work_logs_author_work_date
on programmer_task_work_logs (author_id, work_date);
-- ---------------------------------------------------------------------------
-- 5. ensure_day_sheet(p_programmer, p_date) — idempotent sheet bootstrap
-- SECURITY DEFINER; callers (trigger functions) have no direct table access.
-- ---------------------------------------------------------------------------
create or replace function ensure_day_sheet(
p_programmer uuid,
p_date date
)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_role text;
v_id uuid;
v_status text;
begin
-- Only create sheets for programmers
select role into v_role from profiles where id = p_programmer;
if v_role is distinct from 'programmer' then
return null;
end if;
-- Insert a draft sheet; conflict means the sheet already exists
insert into programmer_day_sheets (programmer_id, work_date, status)
values (p_programmer, p_date, 'draft')
on conflict (programmer_id, work_date) do nothing;
-- Fetch the (potentially pre-existing) sheet id and status
select id, status
into v_id, v_status
from programmer_day_sheets
where programmer_id = p_programmer
and work_date = p_date;
-- If the sheet was previously approved and we are touching today's date,
-- reopen it to pending and record an amended event.
if v_status = 'approved' and p_date = manila_today() then
update programmer_day_sheets
set status = 'pending',
updated_at = now()
where id = v_id;
insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body)
values (v_id, null, 'amended', 'New work was logged after approval.');
end if;
return v_id;
end;
$$;
-- Restrict direct invocation — trigger functions are the only callers
revoke execute on function ensure_day_sheet(uuid, date) from public;
revoke execute on function ensure_day_sheet(uuid, date) from anon;
revoke execute on function ensure_day_sheet(uuid, date) from authenticated;
-- ---------------------------------------------------------------------------
-- 6. BEFORE INSERT trigger on programmer_task_work_logs
-- Validates and normalises work_date; raises work_date_locked when needed.
-- ---------------------------------------------------------------------------
create or replace function validate_work_log_date()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_sheet_status text;
begin
-- Default work_date to today (Manila) when the caller left it null
new.work_date := coalesce(new.work_date, manila_today());
-- Future dates are never allowed
if new.work_date > manila_today() then
raise exception 'work_date_locked' using errcode = 'P0001';
end if;
-- Past dates are allowed only when the author already has an open sheet
-- (status pending or disapproved) for that date — i.e. the window is still
-- editable. No sheet or any other status → locked.
if new.work_date < manila_today() then
select status
into v_sheet_status
from programmer_day_sheets
where programmer_id = new.author_id
and work_date = new.work_date;
if v_sheet_status is null or v_sheet_status not in ('pending', 'disapproved') then
raise exception 'work_date_locked' using errcode = 'P0001';
end if;
end if;
return new;
end;
$$;
drop trigger if exists trg_work_logs_validate_date on programmer_task_work_logs;
create trigger trg_work_logs_validate_date
before insert on programmer_task_work_logs
for each row execute function validate_work_log_date();
-- ---------------------------------------------------------------------------
-- 7. AFTER INSERT trigger on programmer_task_work_logs
-- Ensures a day sheet exists for the author on the work_date.
-- ---------------------------------------------------------------------------
create or replace function after_work_log_ensure_sheet()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
perform ensure_day_sheet(new.author_id, new.work_date);
return null;
end;
$$;
drop trigger if exists trg_work_logs_ensure_sheet on programmer_task_work_logs;
create trigger trg_work_logs_ensure_sheet
after insert on programmer_task_work_logs
for each row execute function after_work_log_ensure_sheet();
-- ---------------------------------------------------------------------------
-- 8. AFTER INSERT trigger on programmer_task_activity_logs
-- Ensures a day sheet for the task's assignee on relevant action types.
-- ---------------------------------------------------------------------------
create or replace function after_activity_log_ensure_sheet()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_assignee uuid;
begin
if new.action_type not in ('started','paused','resumed','completed','cancelled','adjustment') then
return null;
end if;
select assignee_id into v_assignee
from programmer_tasks
where id = new.task_id;
if v_assignee is not null then
perform ensure_day_sheet(
v_assignee,
(new.created_at at time zone 'Asia/Manila')::date
);
end if;
return null;
end;
$$;
drop trigger if exists trg_activity_logs_ensure_sheet on programmer_task_activity_logs;
create trigger trg_activity_logs_ensure_sheet
after insert on programmer_task_activity_logs
for each row execute function after_activity_log_ensure_sheet();
-- ---------------------------------------------------------------------------
-- 9. RLS policies
-- ---------------------------------------------------------------------------
-- programmer_day_sheets: SELECT — own sheet or admin
drop policy if exists "programmer_day_sheets_select" on programmer_day_sheets;
create policy "programmer_day_sheets_select" on programmer_day_sheets
for select to authenticated
using (
programmer_id = auth.uid()
or exists (
select 1 from profiles p
where p.id = auth.uid()
and p.role = 'admin'
)
);
-- programmer_day_sheet_events: SELECT — must be able to see the parent sheet
drop policy if exists "programmer_day_sheet_events_select" on programmer_day_sheet_events;
create policy "programmer_day_sheet_events_select" on programmer_day_sheet_events
for select to authenticated
using (
exists (
select 1 from programmer_day_sheets s
where s.id = sheet_id
and (
s.programmer_id = auth.uid()
or exists (
select 1 from profiles p
where p.id = auth.uid()
and p.role = 'admin'
)
)
)
);
-- No client INSERT/UPDATE/DELETE policies on either table — all writes go
-- through SECURITY DEFINER functions or server-side triggers.
-- ---------------------------------------------------------------------------
-- 10. notifications.day_sheet_id FK
-- ---------------------------------------------------------------------------
alter table notifications
add column if not exists day_sheet_id uuid
references programmer_day_sheets(id) on delete cascade;
create index if not exists idx_notifications_day_sheet_id
on notifications (day_sheet_id)
where day_sheet_id is not null;
-- ---------------------------------------------------------------------------
-- 11. Realtime publication
-- ---------------------------------------------------------------------------
alter publication supabase_realtime add table programmer_day_sheets;
alter publication supabase_realtime add table programmer_day_sheet_events;