diff --git a/src/app/api/stages/[stageId]/request-changes/route.ts b/src/app/api/stages/[stageId]/request-changes/route.ts new file mode 100644 index 0000000..2b193bc --- /dev/null +++ b/src/app/api/stages/[stageId]/request-changes/route.ts @@ -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); + } +} diff --git a/src/components/stages/stage-review-panel.tsx b/src/components/stages/stage-review-panel.tsx index d36ae82..0de3dc6 100644 --- a/src/components/stages/stage-review-panel.tsx +++ b/src/components/stages/stage-review-panel.tsx @@ -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" > - Request changes + {requestingChanges ? "Requesting…" : "Request changes"} )} diff --git a/src/lib/services/stage-transition-service.ts b/src/lib/services/stage-transition-service.ts index d4189bf..3be3d2e 100644 --- a/src/lib/services/stage-transition-service.ts +++ b/src/lib/services/stage-transition-service.ts @@ -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,