fix: project auto-complete trigger + reject reason must persist before flipping status

- Add sync_project_status trigger so projects flip active<->completed with their tasks; backfill existing rows.
- handleSubmitReject now aborts (and alerts) if the rejection-reason submission fails to save, instead of silently flipping status to in_progress and logging a reason-less rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-23 15:04:01 -04:00
parent 8eacd86b04
commit c5f020cf44
2 changed files with 121 additions and 16 deletions
@@ -0,0 +1,95 @@
-- Auto-complete a project when all its tasks are done, and reopen it when they are not.
--
-- Context: projects.status was only ever set to 'active' on insert and never
-- transitioned by application code. The 100% progress bar in the UI is derived
-- purely from task statuses (done / total) and was never written back to
-- projects.status, so projects sat at 'active' forever once all tasks were done.
-- This trigger keeps projects.status in sync with their tasks.
--
-- "Done" mirrors the UI's doneStatuses set. In the live DB the terminal task
-- status is 'client_approved'; 'invoiced'/'paid' are included for parity with the
-- app in case those are ever used as task statuses.
--
-- Only flips between 'active' and 'completed'. Any other status (e.g. a future
-- manual 'cancelled') is left untouched.
create or replace function public.recompute_project_status(p_project_id uuid)
returns void as $$
declare
total_count integer;
done_count integer;
begin
if p_project_id is null then
return;
end if;
select
count(*),
count(*) filter (where status in ('client_approved', 'invoiced', 'paid'))
into total_count, done_count
from public.tasks
where project_id = p_project_id;
if total_count > 0 and done_count = total_count then
update public.projects
set status = 'completed'
where id = p_project_id
and status = 'active';
else
update public.projects
set status = 'active'
where id = p_project_id
and status = 'completed';
end if;
end;
$$ language plpgsql security definer
set search_path = public;
create or replace function public.sync_project_status_from_task()
returns trigger as $$
begin
if tg_op = 'DELETE' then
perform public.recompute_project_status(old.project_id);
return old;
end if;
-- INSERT or UPDATE
perform public.recompute_project_status(new.project_id);
-- A task moved between projects: also recompute the old project.
if tg_op = 'UPDATE' and new.project_id is distinct from old.project_id then
perform public.recompute_project_status(old.project_id);
end if;
return new;
end;
$$ language plpgsql security definer
set search_path = public;
drop trigger if exists sync_project_status on public.tasks;
create trigger sync_project_status
after insert or update or delete on public.tasks
for each row execute function public.sync_project_status_from_task();
-- Backfill existing data to the correct state.
update public.projects p
set status = 'completed'
where p.status = 'active'
and exists (select 1 from public.tasks t where t.project_id = p.id)
and not exists (
select 1 from public.tasks t
where t.project_id = p.id
and t.status not in ('client_approved', 'invoiced', 'paid')
);
update public.projects p
set status = 'active'
where p.status = 'completed'
and (
not exists (select 1 from public.tasks t where t.project_id = p.id)
or exists (
select 1 from public.tasks t
where t.project_id = p.id
and t.status not in ('client_approved', 'invoiced', 'paid')
)
);