From e2ff0140ef61c7a3ae4b67bb5ec31acaa667313c Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 27 Sep 2026 22:51:41 +0800 Subject: [PATCH] feat(db): programmer day sheets schema, triggers, RLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...260927150000_add_programmer_day_sheets.sql | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 supabase/migrations/20260927150000_add_programmer_day_sheets.sql diff --git a/supabase/migrations/20260927150000_add_programmer_day_sheets.sql b/supabase/migrations/20260927150000_add_programmer_day_sheets.sql new file mode 100644 index 00000000..eeaef098 --- /dev/null +++ b/supabase/migrations/20260927150000_add_programmer_day_sheets.sql @@ -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;