Auto-advance fix + NONE stages = Open/Close only

The earlier auto-advance commit (f0eb29d) didn't actually fire on
dynamic pipelines because the dependency engine was still reading
template.dependsOn — the legacy seed graph, almost always empty for
pipelines built in the new editor. Producers saw downstream stages
stay BLOCKED forever no matter how many terminal transitions fired.

dependency-engine.ts:
  - Resolves prerequisites against three sources in priority order:
    1. stageDefinition.dependsOn (V2 dynamic edges, by stageDefinitionId)
    2. template.dependsOn (legacy seed graph)
    3. order fallback (depend on the immediately previous-order stage)
  - getStageIdsToUnblock now also considers NOT_STARTED stages, not
    just BLOCKED ones — when a gate completes, every waiting stage
    flips to IN_PROGRESS.

stage-service.ts:
  - All four allStages.map blocks now include `order` + a normalised
    `stageDefinition` so the engine sees the dynamic graph.
  - Stage fetches include stageDefinition.dependsOn.
  - Bulk update path: unblock target is IN_PROGRESS now (was
    NOT_STARTED) to match the single-update behaviour.

deliverable detail page:
  - NONE-approval stages use a minimal transition set:
    NOT_STARTED → IN_PROGRESS → APPROVED → IN_PROGRESS (reopen).
    No more Submit-for-Review / Approve / Mark-Delivered / Request-
    Changes / Skip noise on stages that have no review concept.
  - The APPROVED button on a NONE stage reads "Mark Complete" so the
    affordance signals workflow close, not a review approval.

Net effect on a typical 5-stage pipeline:
  Click Start Work on stage 1 → Mark Complete → stage 2 auto-opens
  → Mark Complete → stage 3 auto-opens, and so on. One terminal
  click per stage. FORMAL/SIMPLE stages still go through the
  StageReviewPanel for the review flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-15 19:29:41 -04:00
parent f0eb29dd0c
commit 819288d36c
3 changed files with 168 additions and 61 deletions

View file

@ -351,7 +351,34 @@ export default function DeliverableDetailPage() {
</h2>
<div className="flex flex-col gap-1.5">
{stages.map((stage: any) => {
const available = TRANSITIONS[stage.status] ?? [];
// Per-stage approval type drives both the inline transition
// set AND whether the StageReviewPanel below the row owns
// the action buttons. NONE = open/close only (Start Work,
// Mark Complete, Reopen — nothing else). SIMPLE/FORMAL hand
// off to the review panel.
const stageApprovalType =
stage.stageDefinition?.approvalType ??
stage.template?.approvalType ??
"NONE";
const isNoneApproval = stageApprovalType === "NONE";
// For NONE stages, override the transition set with a minimal
// open/close shape so producers never see Submit-for-Review,
// Approve, Request-Changes, Mark-Delivered on a non-review row.
const NONE_TRANSITIONS: Record<string, string[]> = {
BLOCKED: [],
NOT_STARTED: ["IN_PROGRESS"],
IN_PROGRESS: ["APPROVED"],
IN_REVIEW: ["APPROVED"],
CHANGES_REQUESTED: ["IN_PROGRESS"],
APPROVED: ["IN_PROGRESS"],
DELIVERED: ["IN_PROGRESS"],
SKIPPED: ["NOT_STARTED"],
};
const available = isNoneApproval
? NONE_TRANSITIONS[stage.status] ?? []
: TRANSITIONS[stage.status] ?? [];
const assignments = stage.assignments ?? [];
const isGate = stage.stageDefinition?.isCriticalGate ?? stage.template.isCriticalGate;
const isOptional = stage.stageDefinition?.isOptional ?? stage.template.isOptional;
@ -474,9 +501,15 @@ export default function DeliverableDetailPage() {
stage.status === "DELIVERED")) ||
(nextStatus === "NOT_STARTED" &&
stage.status === "SKIPPED");
// NONE-approval stages don't have the review
// concept — relabel APPROVED to "Mark Complete"
// so the affordance reads as a workflow close,
// not a review approval.
const label = isReopen
? "Reopen"
: (TRANSITION_LABELS[nextStatus] ?? nextStatus);
: isNoneApproval && nextStatus === "APPROVED"
? "Mark Complete"
: (TRANSITION_LABELS[nextStatus] ?? nextStatus);
return (
<Button

View file

@ -3,6 +3,8 @@ import type { StageStatus } from "@/generated/prisma/client";
interface StageWithDeps {
id: string;
status: StageStatus;
/** Pipeline order — used as a fallback when no explicit deps exist. */
order: number;
template: {
id: string;
slug: string;
@ -10,60 +12,98 @@ interface StageWithDeps {
isOptional: boolean;
dependsOn: { prerequisiteId: string }[];
};
/**
* Dynamic pipeline edges (PipelineStageDependencyV2). When present these
* take precedence over `template.dependsOn` that's the legacy seed
* graph and is almost always empty for dynamic pipelines.
*/
stageDefinition?: {
id: string;
dependsOn: { prerequisiteId: string }[];
} | null;
}
const TERMINAL = new Set<StageStatus>(["APPROVED", "DELIVERED", "SKIPPED"]);
/**
* Returns the IDs of stages that this stage depends on. Resolves against
* three sources in priority order:
* 1. stageDefinition.dependsOn (dynamic V2 edges, by stageDefinitionId)
* 2. template.dependsOn (legacy template-level seed graph)
* 3. order fallback (immediately previous-order stage)
*
* Returns the matching DeliverableStage IDs from `allStages` (not the
* prerequisite-table IDs) so callers can look up status directly.
*/
function resolvePrerequisiteStageIds(
stage: StageWithDeps,
allStages: StageWithDeps[]
): string[] {
const defDeps = stage.stageDefinition?.dependsOn ?? [];
if (defDeps.length > 0) {
const prereqDefIds = defDeps.map((d) => d.prerequisiteId);
return allStages
.filter(
(s) => s.stageDefinition && prereqDefIds.includes(s.stageDefinition.id)
)
.map((s) => s.id);
}
const tmplDeps = stage.template.dependsOn ?? [];
if (tmplDeps.length > 0) {
const prereqTmplIds = tmplDeps.map((d) => d.prerequisiteId);
return allStages
.filter((s) => prereqTmplIds.includes(s.template.id))
.map((s) => s.id);
}
// Order-based fallback: depend on the stage with the immediately
// previous order on this deliverable. Producers building dynamic
// pipelines without drawing explicit forward arrows still get a
// sensible left-to-right cascade.
const ordered = [...allStages].sort((a, b) => a.order - b.order);
const idx = ordered.findIndex((s) => s.id === stage.id);
if (idx <= 0) return [];
return [ordered[idx - 1].id];
}
/**
* Check if a stage can transition to IN_PROGRESS.
* All prerequisite stages must be complete (APPROVED, DELIVERED, or SKIPPED).
* Reopening from terminal states (APPROVED, DELIVERED, SKIPPED) is allowed
* since the stage already passed its dependencies previously.
* Check if a stage can transition to IN_PROGRESS all prerequisite
* stages must be in a terminal state. Reopening from terminal states
* is always allowed (the prereqs were already satisfied when we got
* there the first time).
*/
export function canStageStart(
stage: StageWithDeps,
allStages: StageWithDeps[]
): { allowed: boolean; reason?: string } {
// Allow reopening from terminal states — dependencies were already satisfied
const reopenable = ["APPROVED", "DELIVERED", "SKIPPED"];
if (reopenable.includes(stage.status)) {
return { allowed: true };
}
if (TERMINAL.has(stage.status)) return { allowed: true };
if (stage.status !== "BLOCKED" && stage.status !== "NOT_STARTED") {
return { allowed: false, reason: `Stage is currently ${stage.status}` };
}
const prerequisiteTemplateIds = stage.template.dependsOn.map(
(d) => d.prerequisiteId
);
for (const prereqTemplateId of prerequisiteTemplateIds) {
const prereqStage = allStages.find(
(s) => s.template.id === prereqTemplateId
);
if (!prereqStage) {
const prereqIds = resolvePrerequisiteStageIds(stage, allStages);
for (const prereqId of prereqIds) {
const prereq = allStages.find((s) => s.id === prereqId);
if (!prereq) {
return { allowed: false, reason: "Missing prerequisite stage" };
}
const isComplete =
prereqStage.status === "APPROVED" ||
prereqStage.status === "DELIVERED" ||
prereqStage.status === "SKIPPED";
if (!isComplete) {
if (!TERMINAL.has(prereq.status)) {
return {
allowed: false,
reason: `Prerequisite "${prereqStage.template.slug}" is not yet complete`,
reason: `Prerequisite "${prereq.template.slug}" is not yet complete`,
};
}
}
return { allowed: true };
}
/**
* Get the list of stage IDs that should be unblocked
* when a critical gate stage is approved.
* After a stage hits a terminal state, return every stage that should
* now auto-open. Includes both BLOCKED stages whose prereqs are now
* satisfied AND NOT_STARTED stages waiting on the same gate both
* paths flip to IN_PROGRESS so producers don't have to click.
*/
export function getStageIdsToUnblock(
approvedStage: StageWithDeps,
@ -72,33 +112,27 @@ export function getStageIdsToUnblock(
const unblockIds: string[] = [];
for (const stage of allStages) {
if (stage.status !== "BLOCKED") continue;
// Active stages stay where they are; only consider those still waiting.
if (stage.status !== "BLOCKED" && stage.status !== "NOT_STARTED") {
continue;
}
// Check if this stage depends on the just-approved stage
const dependsOnApproved = stage.template.dependsOn.some(
(d) => d.prerequisiteId === approvedStage.template.id
);
const prereqIds = resolvePrerequisiteStageIds(stage, allStages);
if (prereqIds.length === 0) continue; // No deps → user starts manually.
if (!dependsOnApproved) continue;
// Does this stage depend on the just-approved stage?
if (!prereqIds.includes(approvedStage.id)) continue;
// Check if ALL prerequisites are now met (not just the one we approved)
const allPrereqsMet = stage.template.dependsOn.every((dep) => {
const prereq = allStages.find((s) => s.template.id === dep.prerequisiteId);
// All prereqs satisfied? (Treat the just-approved stage as terminal
// even though its DB row hasn't been refreshed in this snapshot.)
const allPrereqsMet = prereqIds.every((prereqId) => {
if (prereqId === approvedStage.id) return true;
const prereq = allStages.find((s) => s.id === prereqId);
if (!prereq) return false;
// The just-approved stage counts as approved
if (prereq.id === approvedStage.id) return true;
return (
prereq.status === "APPROVED" ||
prereq.status === "DELIVERED" ||
prereq.status === "SKIPPED"
);
return TERMINAL.has(prereq.status);
});
if (allPrereqsMet) {
unblockIds.push(stage.id);
}
if (allPrereqsMet) unblockIds.push(stage.id);
}
return unblockIds;

View file

@ -139,12 +139,14 @@ export async function bulkUpdateStageStatuses(
where: { AND: [{ id: { in: stageIds } }, visibility] },
include: {
template: { include: { dependsOn: true } },
stageDefinition: { include: { dependsOn: true } },
deliverable: {
include: {
project: { select: { id: true, name: true, organizationId: true } },
stages: {
include: {
template: { include: { dependsOn: true } },
stageDefinition: { include: { dependsOn: true } },
},
},
},
@ -184,6 +186,7 @@ export async function bulkUpdateStageStatuses(
const allStages = stage.deliverable.stages.map((s) => ({
id: s.id,
status: s.status,
order: s.stageDefinition?.order ?? s.template.order ?? 0,
template: {
id: s.template.id,
slug: s.template.slug,
@ -191,6 +194,12 @@ export async function bulkUpdateStageStatuses(
isOptional: s.template.isOptional,
dependsOn: s.template.dependsOn,
},
stageDefinition: s.stageDefinition
? {
id: s.stageDefinition.id,
dependsOn: s.stageDefinition.dependsOn ?? [],
}
: null,
}));
const currentStage = allStages.find((s) => s.id === item.stageId)!;
const depCheck = canStageStart(currentStage, allStages);
@ -262,6 +271,7 @@ export async function bulkUpdateStageStatuses(
const allStages = original.deliverable.stages.map((s) => ({
id: s.id,
status: s.id === item.stageId ? matchingInput.newStatus : s.status,
order: s.stageDefinition?.order ?? s.template.order ?? 0,
template: {
id: s.template.id,
slug: s.template.slug,
@ -269,13 +279,21 @@ export async function bulkUpdateStageStatuses(
isOptional: s.template.isOptional,
dependsOn: s.template.dependsOn,
},
stageDefinition: s.stageDefinition
? {
id: s.stageDefinition.id,
dependsOn: s.stageDefinition.dependsOn ?? [],
}
: null,
}));
const approvedStage = allStages.find((s) => s.id === item.stageId)!;
const idsToUnblock = getStageIdsToUnblock(approvedStage, allStages);
if (idsToUnblock.length > 0) {
// Match the singular-update path: auto-advance to IN_PROGRESS
// (not NOT_STARTED) so producers don't have to click Start.
await tx.deliverableStage.updateMany({
where: { id: { in: idsToUnblock } },
data: { status: "NOT_STARTED" },
data: { status: "IN_PROGRESS", startDate: new Date() },
});
}
}
@ -341,21 +359,22 @@ export async function updateStageStatus(
if (!parentLookup) throw new VisibilityError("Stage not found");
await assertProjectVisible(parentLookup.deliverable.projectId, ctx);
// Fetch the stage with its template, dependencies, and sibling stages
// Fetch the stage with its template (legacy seed) AND the dynamic
// stageDefinition (the V2 graph the user actually draws in the
// pipeline editor). The dependency-engine prefers V2 when present,
// falls back to template, then to plain pipeline `order`.
const stage = await prisma.deliverableStage.findUnique({
where: { id: stageId },
include: {
template: {
include: { dependsOn: true },
},
template: { include: { dependsOn: true } },
stageDefinition: { include: { dependsOn: true } },
deliverable: {
include: {
project: { select: { id: true, name: true, organizationId: true } },
stages: {
include: {
template: {
include: { dependsOn: true },
},
template: { include: { dependsOn: true } },
stageDefinition: { include: { dependsOn: true } },
},
},
},
@ -376,6 +395,7 @@ export async function updateStageStatus(
const allStages = stage.deliverable.stages.map((s) => ({
id: s.id,
status: s.status,
order: s.stageDefinition?.order ?? s.template.order ?? 0,
template: {
id: s.template.id,
slug: s.template.slug,
@ -383,6 +403,12 @@ export async function updateStageStatus(
isOptional: s.template.isOptional,
dependsOn: s.template.dependsOn,
},
stageDefinition: s.stageDefinition
? {
id: s.stageDefinition.id,
dependsOn: s.stageDefinition.dependsOn ?? [],
}
: null,
}));
const currentStage = allStages.find((s) => s.id === stageId)!;
@ -412,6 +438,7 @@ export async function updateStageStatus(
const allStages = stage.deliverable.stages.map((s) => ({
id: s.id,
status: s.id === stageId ? newStatus : s.status,
order: s.stageDefinition?.order ?? s.template.order ?? 0,
template: {
id: s.template.id,
slug: s.template.slug,
@ -419,6 +446,12 @@ export async function updateStageStatus(
isOptional: s.template.isOptional,
dependsOn: s.template.dependsOn,
},
stageDefinition: s.stageDefinition
? {
id: s.stageDefinition.id,
dependsOn: s.stageDefinition.dependsOn ?? [],
}
: null,
}));
const approvedStage = allStages.find((s) => s.id === stageId)!;
const idsToUnblock = getStageIdsToUnblock(approvedStage, allStages);
@ -471,6 +504,7 @@ export async function updateStageStatus(
const allStages = stage.deliverable.stages.map((s) => ({
id: s.id,
status: s.id === stageId ? newStatus : s.status,
order: s.stageDefinition?.order ?? s.template.order ?? 0,
template: {
id: s.template.id,
slug: s.template.slug,
@ -478,6 +512,12 @@ export async function updateStageStatus(
isOptional: s.template.isOptional,
dependsOn: s.template.dependsOn,
},
stageDefinition: s.stageDefinition
? {
id: s.stageDefinition.id,
dependsOn: s.stageDefinition.dependsOn ?? [],
}
: null,
}));
const approvedStage = allStages.find((s) => s.id === stageId)!;