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:
parent
926225a05b
commit
47a65d6498
4 changed files with 177 additions and 0 deletions
70
scripts/recompute-deliverable-statuses.ts
Normal file
70
scripts/recompute-deliverable-statuses.ts
Normal 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());
|
||||
75
src/lib/services/deliverable-status-service.ts
Normal file
75
src/lib/services/deliverable-status-service.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue