Request Changes triggers rework + resets intermediate stages

The rework transition logic already existed (applyRework in
stage-transition-service correctly resets stages between the rework
target and the current stage to NOT_STARTED). What was missing was
the wiring: the "Request Changes" button on the FORMAL StageReviewPanel
just set the stage status to CHANGES_REQUESTED in place — it never
fired the rework transition.

New server entry point:
  - src/lib/services/stage-transition-service.ts: requestChangesOnStage()
    looks up rework edges from this stage's PipelineStageDefinition.
    When ≥1 edge exists, picks the lowest-order target (walks furthest
    back — conservative default) and delegates to
    transitionDeliverableToStage, which handles the proper rework
    transition + intermediate stage reset.
    No edge configured → falls back to legacy CHANGES_REQUESTED in
    place. Preserves behaviour for pipelines without rework drawn.

New route:
  - POST /api/stages/:stageId/request-changes — wraps the service.
    Maps TransitionError to 400 with the error's message.

UI:
  - stage-review-panel.tsx: "Request Changes" button now calls the new
    endpoint instead of mutating status directly. Toast tells the
    producer which stage we walked back to ("sent back to <Stage Name>
    — intermediate stages reset") on rework, or plain "Changes
    requested" on in-place. Disables during the round-trip; relevant
    queries invalidate on success.

Net effect on a 5-stage pipeline (Inputfile → Internal Approval →
Approved → Declined → Delivery) with a rework edge
Internal Approval → Inputfile:
  - On Internal Approval, click "Request changes"
  - Deliverable transitions back to Inputfile (IN_PROGRESS)
  - Any intermediate stage approvals are reset to NOT_STARTED
  - Producer iterates on Inputfile, marks complete, the pipeline
    cascades forward again via auto-advance (819288d)

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

View file

@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import {
requestChangesOnStage,
TransitionError,
} from "@/lib/services/stage-transition-service";
type Params = { params: Promise<{ stageId: string }> };
/**
* POST /api/stages/:stageId/request-changes
*
* "Request Changes" from a review surface. If the stage has a rework
* edge configured in the pipeline template, the deliverable walks back
* to that target and every stage in between resets to NOT_STARTED. If
* no edge is configured, the stage is just marked CHANGES_REQUESTED
* in place (legacy behaviour).
*/
export async function POST(_request: Request, { params }: Params) {
const { session, error } = await requireAuth("STAGE_UPDATE");
if (error) return error;
try {
const { stageId } = await params;
try {
await assertOrgAccess("deliverableStage", stageId, session.user.organizationId);
} catch {
return notFound("Stage not found");
}
const ctx = visibilityContextFromSession(session);
const result = await requestChangesOnStage(stageId, ctx);
return NextResponse.json(result);
} catch (e) {
if (e instanceof TransitionError) {
return badRequest(e.message);
}
return serverError(e);
}
}

View file

@ -18,6 +18,8 @@ import {
formatRevisionLabel,
formatNextRevisionLabel,
} from "@/lib/format-revision-label";
import { apiUrl } from "@/lib/api-client";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
type ApprovalType = "NONE" | "SIMPLE" | "FORMAL";
@ -56,6 +58,8 @@ export function StageReviewPanel({
canEdit,
}: StageReviewPanelProps) {
const [showChecklist, setShowChecklist] = useState(false);
const [requestingChanges, setRequestingChanges] = useState(false);
const queryClient = useQueryClient();
const summary = useFeedbackSummary(stageId);
const revisionsQ = useRevisions(stageId);
const createRevision = useCreateRevision(stageId);
@ -91,11 +95,39 @@ export function StageReviewPanel({
}
async function handleRequestChanges() {
// Request-changes triggers a rework transition when the pipeline
// defines one — the deliverable walks back to the rework target and
// every intermediate stage resets. When no rework edge is configured
// it falls back to marking this stage CHANGES_REQUESTED in place
// (legacy behaviour).
setRequestingChanges(true);
try {
await updateStageStatus.mutateAsync({ stageId, status: "CHANGES_REQUESTED" });
toast.success("Changes requested");
const res = await fetch(
apiUrl(`/api/stages/${stageId}/request-changes`),
{ method: "POST", headers: { "Content-Type": "application/json" } }
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || `Failed: ${res.status}`);
}
if (body.mode === "rework" && body.transition?.toStage) {
const t = body.transition.toStage;
toast.success(
`Changes requested — sent back to "${t.name}" (intermediate stages reset)`
);
} else {
toast.success("Changes requested");
}
// Refresh everything the page might be showing about this deliverable.
queryClient.invalidateQueries({ queryKey: ["deliverable", projectId, deliverableId] });
queryClient.invalidateQueries({ queryKey: ["deliverables"] });
queryClient.invalidateQueries({ queryKey: ["revisions", stageId] });
} catch (e: any) {
toast.error(e?.message || "Failed to record changes");
} finally {
setRequestingChanges(false);
}
}
@ -205,12 +237,12 @@ export function StageReviewPanel({
size="sm"
variant="outline"
className="h-6 px-2 text-[12px]"
disabled={updateStageStatus.isPending}
disabled={requestingChanges}
onClick={handleRequestChanges}
title="Mark this stage as needing changes; producer will iterate"
title="If the pipeline has a rework edge from this stage, the deliverable walks back and intermediate stages reset"
>
<XCircle className="mr-1 h-3 w-3" />
Request changes
{requestingChanges ? "Requesting…" : "Request changes"}
</Button>
)}

View file

@ -46,6 +46,83 @@ export class TransitionError extends Error {
}
}
/**
* "Request Changes" from a FORMAL stage's review panel. Looks up any
* configured rework edge from this stage's PipelineStageDefinition; if
* one exists, walks the deliverable back to that target (using the
* normal rework path, which resets intermediate stages). If no edge is
* configured, falls back to marking the current stage CHANGES_REQUESTED
* in place preserves the old behaviour for un-configured pipelines.
*/
export async function requestChangesOnStage(
stageId: string,
ctx: VisibilityContext
): Promise<
| { ok: true; mode: "rework"; transition: TransitionResult }
| { ok: true; mode: "in-place" }
> {
const stage = await prisma.deliverableStage.findUnique({
where: { id: stageId },
select: {
deliverableId: true,
deliverable: { select: { projectId: true } },
stageDefinition: {
select: {
id: true,
slug: true,
reworkTo: {
select: {
toStage: { select: { slug: true, order: true } },
},
},
},
},
},
});
if (!stage) throw new TransitionError("NOT_FOUND", "Stage not found");
await assertProjectVisible(stage.deliverable.projectId, ctx);
const reworkEdges = stage.stageDefinition?.reworkTo ?? [];
// No rework edge configured → just set the current stage to
// CHANGES_REQUESTED and let the producer iterate in place.
if (reworkEdges.length === 0) {
await prisma.deliverableStage.update({
where: { id: stageId },
data: { status: "CHANGES_REQUESTED" },
});
await recomputeDeliverableStatus(stage.deliverableId);
return { ok: true, mode: "in-place" };
}
// Pick the rework target with the LOWEST order — walking the furthest
// back is the conservative default (resets the most intermediate
// work). Producers wanting different behaviour can simplify the
// pipeline graph or call transitionDeliverableToStage directly.
const target = reworkEdges
.map((e) => e.toStage)
.filter((t): t is { slug: string; order: number } => !!t)
.sort((a, b) => a.order - b.order)[0];
if (!target) {
// Shouldn't happen — reworkEdges had entries but no toStage. Be
// defensive and fall back to in-place.
await prisma.deliverableStage.update({
where: { id: stageId },
data: { status: "CHANGES_REQUESTED" },
});
await recomputeDeliverableStatus(stage.deliverableId);
return { ok: true, mode: "in-place" };
}
const result = await transitionDeliverableToStage(
stage.deliverableId,
target.slug,
ctx
);
return { ok: true, mode: "rework", transition: result };
}
export async function transitionDeliverableToStage(
deliverableId: string,
targetStageSlug: string,