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:
parent
819288d36c
commit
aa897354e7
3 changed files with 157 additions and 5 deletions
43
src/app/api/stages/[stageId]/request-changes/route.ts
Normal file
43
src/app/api/stages/[stageId]/request-changes/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue