From 47a65d6498abe73663f726269083c2c2e991a7ce Mon Sep 17 00:00:00 2001 From: DJP Date: Tue, 21 Apr 2026 19:55:39 -0400 Subject: [PATCH] Keep Deliverable.status in sync with stage state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the mismatch: Deliverable.status is a denormalised column that was only written at create-time (default NOT_STARTED) and never refreshed when stages moved. The Projects board read it live and showed "Not Started" while the pipeline ring + dominant- stage view correctly showed "at Client Feedback (6/11 stages complete)". Fix in two parts: 1. New deliverable-status-service with: - computeDeliverableStatus(stageStatuses[]) — pure function with the summary rule: all stages terminal → APPROVED any IN_REVIEW → IN_REVIEW any IN_PROGRESS/CHANGES_REQUESTED → IN_PROGRESS else → NOT_STARTED ON_HOLD is producer-managed and never overwritten. - recomputeDeliverableStatus(deliverableId, txClient?) — executes the rule + writes if different. Accepts an optional Prisma tx client so callers can run inside their own transaction. 2. Wired into every stage-write path: - stage-service.updateStageStatus (single-stage transitions) - stage-service bulk transaction (bulkUpdateStages) — dedups touched deliverable IDs so we don't recompute twice. - stage-transition-service forward + rework (board drag) — inline inside the same $transaction so the board bucket is correct on the next refetch. 3. Backfill script scripts/recompute-deliverable-statuses.ts — one-off sweep to fix existing stale rows: npx tsx scripts/recompute-deliverable-statuses.ts Run once after deploy. --- scripts/recompute-deliverable-statuses.ts | 70 +++++++++++++++++ .../services/deliverable-status-service.ts | 75 +++++++++++++++++++ src/lib/services/stage-service.ts | 22 ++++++ src/lib/services/stage-transition-service.ts | 10 +++ 4 files changed, 177 insertions(+) create mode 100644 scripts/recompute-deliverable-statuses.ts create mode 100644 src/lib/services/deliverable-status-service.ts diff --git a/scripts/recompute-deliverable-statuses.ts b/scripts/recompute-deliverable-statuses.ts new file mode 100644 index 0000000..806c775 --- /dev/null +++ b/scripts/recompute-deliverable-statuses.ts @@ -0,0 +1,70 @@ +/** + * scripts/recompute-deliverable-statuses.ts + * + * One-off backfill for Deliverable.status. Sweeps every deliverable, + * runs the same recompute logic the live write path now uses, and + * updates any that were stale. + * + * Before this commit, Deliverable.status was only written at create + * time and never refreshed, so live stage activity didn't propagate + * to the denormalised column. Run this once after deploy to clean + * up existing rows; from then on the in-app recompute keeps things + * in sync automatically. + * + * npx tsx scripts/recompute-deliverable-statuses.ts + */ + +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../src/generated/prisma/client"; +import { computeDeliverableStatus } from "../src/lib/services/deliverable-status-service"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log("Sweeping deliverable statuses…"); + + const deliverables = await prisma.deliverable.findMany({ + select: { + id: true, + name: true, + status: true, + stages: { select: { status: true } }, + }, + }); + + let scanned = 0; + let updated = 0; + let skippedOnHold = 0; + + for (const d of deliverables) { + scanned++; + // Producer-managed park state — never overwrite based on derived + // values, same as the live recompute helper. + if (d.status === "ON_HOLD") { + skippedOnHold++; + continue; + } + const next = computeDeliverableStatus(d.stages.map((s) => s.status)); + if (next !== d.status) { + await prisma.deliverable.update({ + where: { id: d.id }, + data: { status: next }, + }); + console.log(` ${d.name}: ${d.status} → ${next}`); + updated++; + } + } + + console.log( + `\nDone. scanned=${scanned} updated=${updated} skipped-on-hold=${skippedOnHold}` + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/lib/services/deliverable-status-service.ts b/src/lib/services/deliverable-status-service.ts new file mode 100644 index 0000000..28850e0 --- /dev/null +++ b/src/lib/services/deliverable-status-service.ts @@ -0,0 +1,75 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; + +/** + * Deliverable.status is a denormalised field — it summarises the + * state of the deliverable's stages for sort/filter/board-grouping + * without needing to aggregate on every read. This helper keeps it + * in sync after any stage-level write. + * + * Rules: + * - All stages terminal (APPROVED/DELIVERED/SKIPPED) → APPROVED + * - Any stage IN_REVIEW → IN_REVIEW + * - Any stage IN_PROGRESS or CHANGES_REQUESTED → IN_PROGRESS + * - Otherwise (empty / all NOT_STARTED or BLOCKED) → NOT_STARTED + * + * ON_HOLD is user-driven — a producer intentionally parks the whole + * deliverable. We don't overwrite that based on stage state; any + * subsequent stage activity stays silent until someone lifts the + * hold manually. + * + * The helper accepts an optional Prisma transaction client so callers + * inside a $transaction can atomically recompute alongside their + * stage writes. + */ + +type DeliverableStatus = + | "NOT_STARTED" + | "IN_PROGRESS" + | "IN_REVIEW" + | "APPROVED" + | "ON_HOLD"; + +const TERMINAL_STAGE = new Set(["APPROVED", "DELIVERED", "SKIPPED"]); +const REVIEWING_STAGE = new Set(["IN_REVIEW"]); +const ACTIVE_STAGE = new Set(["IN_PROGRESS", "CHANGES_REQUESTED"]); + +export function computeDeliverableStatus( + stageStatuses: string[] +): DeliverableStatus { + if (stageStatuses.length === 0) return "NOT_STARTED"; + if (stageStatuses.every((s) => TERMINAL_STAGE.has(s))) return "APPROVED"; + if (stageStatuses.some((s) => REVIEWING_STAGE.has(s))) return "IN_REVIEW"; + if (stageStatuses.some((s) => ACTIVE_STAGE.has(s))) return "IN_PROGRESS"; + return "NOT_STARTED"; +} + +type TxClient = Prisma.TransactionClient | typeof prisma; + +export async function recomputeDeliverableStatus( + deliverableId: string, + client: TxClient = prisma +): Promise<{ changed: boolean; status: DeliverableStatus | "ON_HOLD" }> { + const row = await client.deliverable.findUnique({ + where: { id: deliverableId }, + select: { + status: true, + stages: { select: { status: true } }, + }, + }); + if (!row) return { changed: false, status: "NOT_STARTED" }; + + // Producer-managed park state — don't clobber with a computed value. + if (row.status === "ON_HOLD") { + return { changed: false, status: "ON_HOLD" }; + } + + const next = computeDeliverableStatus(row.stages.map((s) => s.status)); + if (next === row.status) return { changed: false, status: next }; + + await client.deliverable.update({ + where: { id: deliverableId }, + data: { status: next }, + }); + return { changed: true, status: next }; +} diff --git a/src/lib/services/stage-service.ts b/src/lib/services/stage-service.ts index d9e15fd..9c83af1 100644 --- a/src/lib/services/stage-service.ts +++ b/src/lib/services/stage-service.ts @@ -3,6 +3,7 @@ import { canTransition } from "@/lib/pipeline/stage-machine"; import { canStageStart, getStageIdsToUnblock } from "@/lib/pipeline/dependency-engine"; import type { StageStatus } from "@/generated/prisma/client"; import { emitStageStatusChanged } from "@/lib/automation/event-bus"; +import { recomputeDeliverableStatus } from "@/lib/services/deliverable-status-service"; import { assertProjectVisible, visibleStagesWhere, @@ -279,6 +280,19 @@ export async function bulkUpdateStageStatuses( } } } + + // Recompute the denormalised Deliverable.status for every + // deliverable touched by this bulk. Dedup by deliverable id so + // we don't recompute the same one twice when several stages + // change within it. + const touchedDeliverableIds = new Set( + succeeded + .map((s) => stageMap.get(s.stageId)?.deliverableId) + .filter((id): id is string => !!id) + ); + for (const id of touchedDeliverableIds) { + await recomputeDeliverableStatus(id, tx); + } }); } @@ -476,6 +490,14 @@ export async function updateStageStatus( return { success: true, stage: updated }; }); + // Keep the parent deliverable's denormalised status in sync with + // the stage state so the per-project board (which groups by + // Deliverable.status) doesn't disagree with stage-derived views. + // Best-effort — a failure here shouldn't break the stage write. + await recomputeDeliverableStatus(stage.deliverable.id).catch((err) => { + console.error("[DeliverableStatus] recompute failed:", err); + }); + // Emit automation event AFTER transaction commits if (result.success) { emitStageStatusChanged(stage.deliverable.project.organizationId, { diff --git a/src/lib/services/stage-transition-service.ts b/src/lib/services/stage-transition-service.ts index 8707d1f..d4189bf 100644 --- a/src/lib/services/stage-transition-service.ts +++ b/src/lib/services/stage-transition-service.ts @@ -3,6 +3,7 @@ import { assertProjectVisible, type VisibilityContext, } from "@/lib/rbac/visibility"; +import { recomputeDeliverableStatus } from "@/lib/services/deliverable-status-service"; /** * Board-drag transition logic for deliverables. @@ -243,6 +244,11 @@ async function applyForward( where: { id: targetRow.id }, data: { status: "IN_PROGRESS", startDate: new Date() }, }); + + // Keep Deliverable.status in sync with the new stage state — + // inside the same transaction so the board view re-renders with + // the correct bucket immediately on refetch. + await recomputeDeliverableStatus(deliverableId, tx); }); return { @@ -323,6 +329,10 @@ async function applyRework( }, data: { status: "NOT_STARTED", completedDate: null }, }); + + // Recompute the deliverable summary in-transaction so the board + // bucket is right on the next refetch. + await recomputeDeliverableStatus(deliverableId, tx); }); return {