e2ff0140ef
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>
312 lines
12 KiB
PL/PgSQL
312 lines
12 KiB
PL/PgSQL
-- 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;
|