Keep Deliverable.status in sync with stage state

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.
This commit is contained in:
DJP 2026-04-21 19:55:39 -04:00
parent 926225a05b
commit 47a65d6498
4 changed files with 177 additions and 0 deletions

View file

@ -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());

View file

@ -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 };
}

View file

@ -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, {

View file

@ -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 {