fix(ara-projects): key the stages routes on the shared [workstreamId] slug

Next.js refuses two different slug names for one dynamic segment, and #410
already owns `/api/projects/[projectId]/workstreams/[workstreamId]`. Adding
`[projectWorkstreamId]` beside it was a hard boot/build failure that took
the whole app down, not just these routes — `next dev` refused to start.

The URL shape is unchanged; the id in that segment is now the catalogue
`workstream.id` throughout, so `/workstreams/9` and `/workstreams/9/stages`
name the same workstream. `findLadder` translates to the
`project_workstream.id` the writes take, once per request, at the edge —
keeping that id out of URLs entirely. ADR-0021 records the trade-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-23 13:38:41 +00:00
parent 42c027a5c5
commit ab29a246a4
13 changed files with 151 additions and 83 deletions

View file

@ -1,4 +1,4 @@
# 21. Stage ladders are seeded on entering setup, and `status` is written once as a placeholder
# 21. Stage ladders: seeded on entering setup, keyed on `workstream.id`, and `status` written once as a placeholder
Date: 2026-07-23
@ -16,7 +16,7 @@ highest `order` (CONTEXT.md, **Stage**). `work_order.status`,
`work_order.priority` and `project_workstream.current_stage_id` are deliberately
untouched, deferred to #426.
Two things about that had to be decided before the screen could be written.
Three things about that had to be decided before the screen could be written.
### 1. Where the default ladder gets seeded
@ -29,7 +29,21 @@ of import for that workstream.
The candidates were: seed at workstream selection (#410's POST), seed on the
first GET of the stages API, or seed when the setup screen is entered.
### 2. `project_workstream_stage.status`
### 2. Which id the stage routes are keyed on
Issue #411 specifies the CRUD routes as
`/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages`. That
directory cannot exist: #410 already owns
`/api/projects/[projectId]/workstreams/[workstreamId]` for the deselect
endpoint, and Next.js refuses two different slug *names* for the same dynamic
path segment — "You cannot use different slug names for the same dynamic path
(`projectWorkstreamId` !== `workstreamId`)". It is a hard failure at boot and at
build, not a warning, and it takes the whole app down rather than just these
routes.
So the segment is shared, and the only question left is what the id in it means.
### 3. `project_workstream_stage.status`
The column is `text NOT NULL` with no database default (migration 0273). Its
value set is still TBC — the schema's own comment says so — and #411's
@ -40,6 +54,13 @@ taken literally.
## Decision
**The stage routes are keyed on `workstream.id`**, the catalogue row — the same
id the shared `[workstreamId]` segment already means for #410's deselect. The
URL shape the issue asked for is unchanged (`/workstreams/9/stages`); only the
id space is. `findLadder(projectId, workstreamId)` resolves it to the
`project_workstream.id` that every write function takes, exactly once per
request, at the edge.
**Seeding happens in the setup page's server component**, before it reads, and
only for a caller who may manage the project. `seedDefaultLadders(projectId)`
selects the project's `project_workstream` rows `FOR UPDATE`, re-reads their
@ -59,6 +80,18 @@ never written by #411 — the AC holds for both columns as stated. A stage that
## Consequences
- **One URL segment means one thing.** `/workstreams/9` and
`/workstreams/9/stages` name the same workstream. The alternative — keeping
`project_workstream.id` under a segment that means `workstream.id` one level
up — would have type-checked, passed its tests, and quietly addressed a
different workstream whenever the two ids diverged. Nothing in the URL would
have shown it.
- **The `project_workstream.id` never appears in a URL**, so it stays an
internal identifier. It is still what the readiness helper (#414) and the hub
(#444) pass around in-process, and it is still what the ladder payload carries
for the client that needs it.
- The resolution costs one extra join per request, on a query the handler was
making anyway to prove the workstream belongs to the project.
- **The seed is idempotent under concurrency, not merely on a revisit.** Two
simultaneous first visits cannot both find "no stages" and both insert: the
second transaction blocks on the row lock, then re-reads and finds the ladder.
@ -85,6 +118,7 @@ never written by #411 — the AC holds for both columns as stated. A stage that
## References
- Issue #411 — Ara Projects: setup wizard 3/5, stage configuration
- Issue #410 — workstream selection, which owns the `[workstreamId]` segment
- CONTEXT.md, **Stage** and **Work order**
- [ADR-0019](./0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md)
— import is setup-gated, which is what makes an unseeded ladder visible to the

View file

@ -1,5 +1,5 @@
/**
* PATCH / DELETE `.../stages/[stageId]` (issue #411).
* PATCH / DELETE `.../workstreams/[workstreamId]/stages/[stageId]` (#411).
*
* Database mocked at the module boundary (`../queries` and the authz
* repository); no connection is opened. The blocked-delete path the one the
@ -94,13 +94,15 @@ function projectExists() {
const params = (stageId = "100") => ({
params: Promise.resolve({
projectId: "5",
projectWorkstreamId: "9",
// The catalogue `workstream.id`; the mocked ladder maps it to
// `project_workstream.id` 9, which is what the writes are asserted on.
workstreamId: "1",
stageId,
}),
});
const url = (stageId = "100") =>
`http://localhost/api/projects/5/workstreams/9/stages/${stageId}`;
`http://localhost/api/projects/5/workstreams/1/stages/${stageId}`;
function patchRequest(body: unknown, stageId = "100") {
return new NextRequest(url(stageId), {

View file

@ -1,5 +1,5 @@
/**
* `.../workstreams/[projectWorkstreamId]/stages/[stageId]` (issue #411).
* `.../workstreams/[workstreamId]/stages/[stageId]` (issue #411).
*
* PATCH rename a stage and/or set its optional start / due dates.
* DELETE remove a stage, subject to the ladder guardrails.
@ -23,7 +23,8 @@ import { deleteStage, findLadder, updateStage } from "../queries";
type Params = {
params: Promise<{
projectId: string;
projectWorkstreamId: string;
/** The catalogue `workstream.id` — see `../route.ts` and ADR-0021. */
workstreamId: string;
stageId: string;
}>;
};
@ -57,13 +58,13 @@ function parseId(raw: string): bigint | null {
/** PATCH — rename and/or re-date one stage. */
export async function PATCH(req: NextRequest, props: Params) {
const { projectId, projectWorkstreamId, stageId } = await props.params;
const { projectId, workstreamId: workstreamIdParam, stageId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseId(projectWorkstreamId);
const workstreamId = parseId(workstreamIdParam);
const id = parseId(stageId);
if (workstreamId === null || id === null) {
return NextResponse.json({ error: "Invalid stage" }, { status: 400 });
@ -100,7 +101,11 @@ export async function PATCH(req: NextRequest, props: Params) {
}
try {
const result = await updateStage(workstreamId, id, body);
const result = await updateStage(
BigInt(ladder.projectWorkstreamId),
id,
body,
);
if (result.kind === "not-found") {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
}
@ -119,19 +124,20 @@ export async function PATCH(req: NextRequest, props: Params) {
/** DELETE — remove a stage if the guardrails allow it, then close the gap. */
export async function DELETE(_req: NextRequest, props: Params) {
const { projectId, projectWorkstreamId, stageId } = await props.params;
const { projectId, workstreamId: workstreamIdParam, stageId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseId(projectWorkstreamId);
const workstreamId = parseId(workstreamIdParam);
const id = parseId(stageId);
if (workstreamId === null || id === null) {
return NextResponse.json({ error: "Invalid stage" }, { status: 400 });
}
if (!(await findLadder(auth.projectId, workstreamId))) {
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
@ -139,7 +145,7 @@ export async function DELETE(_req: NextRequest, props: Params) {
}
try {
const result = await deleteStage(workstreamId, id);
const result = await deleteStage(BigInt(ladder.projectWorkstreamId), id);
if (result.kind === "not-found") {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });

View file

@ -161,10 +161,16 @@ function toLadder(
};
}
/** The Project Workstreams a project has selected, catalogue name included. */
/**
* The Project Workstreams a project has selected, catalogue name included.
*
* `workstreamId` narrows to one, and is the **catalogue** `workstream.id`
* see `findLadder` for why the routes are keyed on that rather than on
* `project_workstream.id`.
*/
async function loadSelectedWorkstreams(
projectId: bigint,
projectWorkstreamId?: bigint,
workstreamId?: bigint,
) {
return db
.select({
@ -175,11 +181,11 @@ async function loadSelectedWorkstreams(
.from(projectWorkstream)
.innerJoin(workstream, eq(workstream.id, projectWorkstream.workstreamId))
.where(
projectWorkstreamId === undefined
workstreamId === undefined
? eq(projectWorkstream.projectId, projectId)
: and(
eq(projectWorkstream.projectId, projectId),
eq(projectWorkstream.id, projectWorkstreamId),
eq(projectWorkstream.workstreamId, workstreamId),
),
)
.orderBy(projectWorkstream.id);
@ -215,25 +221,33 @@ export async function listProjectLadders(
}
/**
* One Project Workstream's ladder, or null when `projectWorkstreamId` is not a
* workstream of `projectId`.
* One workstream's ladder on one project, or null when that project has not
* selected it.
*
* **Keyed on `workstream.id`**, the catalogue row not on
* `project_workstream.id`. The stages routes are nested under the same
* `/workstreams/[workstreamId]` segment as the deselect endpoint (#410), and a
* URL segment must mean one thing: `/workstreams/9` and `/workstreams/9/stages`
* name the same workstream. See ADR-0021.
*
* The null is load-bearing: it is how every route handler here checks that the
* workstream in the path belongs to the project in the path, so a valid id
* from another project reads as 404 rather than being edited.
* workstream in the path is selected on the project in the path, so an id from
* another project reads as 404 rather than being edited. The returned
* `projectWorkstreamId` is what the write functions below take, so the
* translation happens exactly once per request, at the edge.
*/
export async function findLadder(
projectId: bigint,
projectWorkstreamId: bigint,
workstreamId: bigint,
): Promise<WorkstreamLadder | null> {
const [workstreamRow] = await loadSelectedWorkstreams(
projectId,
projectWorkstreamId,
);
const [workstreamRow] = await loadSelectedWorkstreams(projectId, workstreamId);
if (!workstreamRow) return null;
const rows = await loadStageRows(
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId),
eq(
projectWorkstreamStage.projectWorkstreamId,
workstreamRow.projectWorkstreamId,
),
);
return toLadder(workstreamRow, rows);
}

View file

@ -1,5 +1,9 @@
/**
* GET / POST / PATCH `.../workstreams/[projectWorkstreamId]/stages` (#411).
* GET / POST / PATCH `.../workstreams/[workstreamId]/stages` (issue #411).
*
* The id in the path is the catalogue `workstream.id`; `findLadder` translates
* it to the `project_workstream.id` the writes take (ADR-0021), so the mocked
* ladder below is what decides which workstream is written to.
*
* The database is mocked at the module boundary (`./queries` and the authz
* repository); no connection is opened. The authorization *decisions* are the
@ -91,12 +95,12 @@ function projectExists() {
});
}
const params = (projectId = "5", projectWorkstreamId = "9") => ({
params: Promise.resolve({ projectId, projectWorkstreamId }),
const params = (projectId = "5", workstreamId = "1") => ({
params: Promise.resolve({ projectId, workstreamId }),
});
const url = (projectId = "5", projectWorkstreamId = "9") =>
`http://localhost/api/projects/${projectId}/workstreams/${projectWorkstreamId}/stages`;
const url = (projectId = "5", workstreamId = "1") =>
`http://localhost/api/projects/${projectId}/workstreams/${workstreamId}/stages`;
function jsonRequest(method: string, body: unknown) {
return new NextRequest(url(), {
@ -150,7 +154,7 @@ describe("GET", () => {
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ladder: LADDER, canManage: true });
expect(mockFindLadder).toHaveBeenCalledWith(5n, 9n);
expect(mockFindLadder).toHaveBeenCalledWith(5n, 1n);
});
it("lets a contractor read the ladder but not manage it", async () => {

View file

@ -1,5 +1,5 @@
/**
* `/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages` (#411).
* `/api/projects/[projectId]/workstreams/[workstreamId]/stages` (issue #411).
*
* GET one workstream's ladder, stages in `order`, with the counts that
* explain why a stage may not be deletable.
@ -9,11 +9,13 @@
* Rename, dates and delete are keyed on a single stage and live on
* `./[stageId]/route.ts`.
*
* Keyed on `project_workstream.id`, not `workstream.id`: a ladder belongs to
* the workstream *as configured on this project*, and that is the id the setup
* hub and the readiness helper (#414) both already hand around. A workstream
* of another project is a 404 here `findLadder` scopes every read to the
* project in the path.
* Keyed on `workstream.id` the catalogue row because these routes are
* nested under the same `/workstreams/[workstreamId]` segment as the deselect
* endpoint (#410), and one segment must mean one thing. Next.js enforces the
* shared slug name; the shared *meaning* is ours to keep. `findLadder`
* translates to the `project_workstream.id` the writes take, once, and returns
* null for a workstream this project has not selected which is how a
* cross-project id becomes a 404 rather than an edit. See ADR-0021.
*
* Authorization is `../../authorize` unchanged (#410): view to read, manage to
* write, and a project the caller cannot see reads as 404 rather than 403.
@ -25,7 +27,7 @@ import { checkStageDates } from "./ladder";
import { addStage, findLadder, reorderStages } from "./queries";
type Params = {
params: Promise<{ projectId: string; projectWorkstreamId: string }>;
params: Promise<{ projectId: string; workstreamId: string }>;
};
/** `YYYY-MM-DD` — see ADR-0019: the display format stops at the input's edge. */
@ -53,20 +55,20 @@ const reorderSchema = z.object({
stageIds: z.array(z.string().regex(/^\d+$/, "Stage ids must be numeric")),
});
/** Parses `[projectWorkstreamId]`; null for anything that is not a bare id. */
/** Parses `[workstreamId]`; null for anything that is not a bare id. */
function parseWorkstreamId(raw: string): bigint | null {
return /^\d+$/.test(raw) ? BigInt(raw) : null;
}
/** GET — the ladder, re-read from the database on every request. */
export async function GET(_req: NextRequest, props: Params) {
const { projectId, projectWorkstreamId } = await props.params;
const { projectId, workstreamId: workstreamIdParam } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "view");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseWorkstreamId(projectWorkstreamId);
const workstreamId = parseWorkstreamId(workstreamIdParam);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
@ -84,13 +86,13 @@ export async function GET(_req: NextRequest, props: Params) {
/** POST — append a stage. */
export async function POST(req: NextRequest, props: Params) {
const { projectId, projectWorkstreamId } = await props.params;
const { projectId, workstreamId: workstreamIdParam } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseWorkstreamId(projectWorkstreamId);
const workstreamId = parseWorkstreamId(workstreamIdParam);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
@ -107,9 +109,10 @@ export async function POST(req: NextRequest, props: Params) {
return NextResponse.json({ error: dates.error }, { status: 400 });
}
// Existence and ownership before the write: an id from another project must
// never reach `addStage`.
if (!(await findLadder(auth.projectId, workstreamId))) {
// Existence and ownership before the write, and the translation from the
// catalogue id in the path to the `project_workstream.id` the write takes.
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
@ -117,7 +120,7 @@ export async function POST(req: NextRequest, props: Params) {
}
try {
const result = await addStage(workstreamId, {
const result = await addStage(BigInt(ladder.projectWorkstreamId), {
name: body.name,
startDate: body.startDate,
dueDate: body.dueDate,
@ -128,7 +131,7 @@ export async function POST(req: NextRequest, props: Params) {
}
return NextResponse.json({ stageId: result.stageId }, { status: 201 });
} catch (err) {
console.error("POST .../workstreams/[id]/stages failed:", err);
console.error("POST .../workstreams/[workstreamId]/stages failed:", err);
return NextResponse.json(
{ error: "Couldn't add the stage" },
{ status: 500 },
@ -144,13 +147,13 @@ export async function POST(req: NextRequest, props: Params) {
* the ladder is rejected as stale (409) rather than partially applied.
*/
export async function PATCH(req: NextRequest, props: Params) {
const { projectId, projectWorkstreamId } = await props.params;
const { projectId, workstreamId: workstreamIdParam } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseWorkstreamId(projectWorkstreamId);
const workstreamId = parseWorkstreamId(workstreamIdParam);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
@ -162,7 +165,8 @@ export async function PATCH(req: NextRequest, props: Params) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
if (!(await findLadder(auth.projectId, workstreamId))) {
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
@ -170,13 +174,16 @@ export async function PATCH(req: NextRequest, props: Params) {
}
try {
const result = await reorderStages(workstreamId, body.stageIds);
const result = await reorderStages(
BigInt(ladder.projectWorkstreamId),
body.stageIds,
);
if (result.kind === "rejected") {
return NextResponse.json({ error: result.reason }, { status: 409 });
}
return NextResponse.json({ reordered: true });
} catch (err) {
console.error("PATCH .../workstreams/[id]/stages failed:", err);
console.error("PATCH .../workstreams/[workstreamId]/stages failed:", err);
return NextResponse.json(
{ error: "Couldn't reorder the stages" },
{ status: 500 },

View file

@ -1,8 +1,8 @@
"use client";
import { Layers } from "lucide-react";
import { DEFAULT_LADDER } from "@/app/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages/ladder";
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages/queries";
import { DEFAULT_LADDER } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder";
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
/**
* The "Workstream overrides" panel from the wireframe (issue #411).
@ -42,11 +42,11 @@ export function LadderOverridesPanel({
<ul className="flex flex-wrap gap-2">
{ladders.map((ladder) => (
<li key={ladder.projectWorkstreamId}>
<li key={ladder.workstreamId}>
<a
href={`#ladder-${ladder.projectWorkstreamId}`}
href={`#ladder-${ladder.workstreamId}`}
className="flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs transition hover:border-gray-300"
data-testid={`ladder-override-${ladder.projectWorkstreamId}`}
data-testid={`ladder-override-${ladder.workstreamId}`}
data-default={ladder.isDefault}
>
<span className="font-semibold text-gray-800">

View file

@ -8,11 +8,11 @@ import { Input } from "@/app/shadcn_components/ui/input";
import { DateInput } from "@/app/components/DateInput";
import { ConfirmDialog } from "@/app/components/ConfirmDialog";
import { IMPOSSIBLE_DATE_MESSAGE, isIsoDate } from "./dateEntry";
import { moveStage } from "@/app/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages/ladder";
import { moveStage } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder";
import type {
LadderStage,
WorkstreamLadder,
} from "@/app/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages/queries";
} from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
import { StageRow } from "./StageRow";
/**
@ -52,7 +52,7 @@ export function StageLadderEditor({
const [adding, setAdding] = useState(false);
const [pendingDelete, setPendingDelete] = useState<LadderStage | null>(null);
const base = `/api/projects/${projectId}/workstreams/${ladder.projectWorkstreamId}/stages`;
const base = `/api/projects/${projectId}/workstreams/${ladder.workstreamId}/stages`;
/** POST/PATCH/DELETE with the project's `{ error: string }` convention. */
async function send(
@ -179,9 +179,9 @@ export function StageLadderEditor({
return (
<section
id={`ladder-${ladder.projectWorkstreamId}`}
id={`ladder-${ladder.workstreamId}`}
className="scroll-mt-6 rounded-xl border border-gray-200 bg-white p-5"
data-testid={`stage-ladder-${ladder.projectWorkstreamId}`}
data-testid={`stage-ladder-${ladder.workstreamId}`}
data-default={ladder.isDefault}
>
<header className="mb-4 flex flex-wrap items-center justify-between gap-2">
@ -196,7 +196,7 @@ export function StageLadderEditor({
? "bg-gray-100 text-gray-600"
: "bg-indigo-50 text-indigo-700",
].join(" ")}
data-testid={`ladder-badge-${ladder.projectWorkstreamId}`}
data-testid={`ladder-badge-${ladder.workstreamId}`}
>
{ladder.isDefault ? "Default ladder" : "Custom ladder"}
</span>
@ -218,7 +218,7 @@ export function StageLadderEditor({
: "border-gray-200 bg-gray-50 text-gray-600",
].join(" ")}
role="alert"
data-testid={`ladder-notice-${ladder.projectWorkstreamId}`}
data-testid={`ladder-notice-${ladder.workstreamId}`}
>
{notice.text}
</p>
@ -227,7 +227,7 @@ export function StageLadderEditor({
{ladder.stages.length === 0 ? (
<p
className="rounded-lg border border-dashed border-gray-200 px-3 py-6 text-center text-sm text-gray-500"
data-testid={`ladder-empty-${ladder.projectWorkstreamId}`}
data-testid={`ladder-empty-${ladder.workstreamId}`}
>
This workstream has no stages, so work orders can&rsquo;t be imported
against it yet.
@ -256,7 +256,7 @@ export function StageLadderEditor({
{adding ? (
<div
className="flex flex-wrap items-end gap-3"
data-testid={`add-stage-form-${ladder.projectWorkstreamId}`}
data-testid={`add-stage-form-${ladder.workstreamId}`}
>
<label className="flex min-w-[200px] flex-1 flex-col gap-1">
<span className="text-xs font-medium text-gray-500">
@ -275,7 +275,7 @@ export function StageLadderEditor({
submitAdd();
}
}}
data-testid={`add-stage-name-${ladder.projectWorkstreamId}`}
data-testid={`add-stage-name-${ladder.workstreamId}`}
/>
</label>
<label className="flex w-[150px] flex-col gap-1">
@ -288,7 +288,7 @@ export function StageLadderEditor({
onChange={(value) =>
setDraft((d) => ({ ...d, startDate: value }))
}
data-testid={`add-stage-start-${ladder.projectWorkstreamId}`}
data-testid={`add-stage-start-${ladder.workstreamId}`}
/>
</label>
<label className="flex w-[150px] flex-col gap-1">
@ -301,13 +301,13 @@ export function StageLadderEditor({
onChange={(value) =>
setDraft((d) => ({ ...d, dueDate: value }))
}
data-testid={`add-stage-due-${ladder.projectWorkstreamId}`}
data-testid={`add-stage-due-${ladder.workstreamId}`}
/>
</label>
<Button
onClick={submitAdd}
disabled={busy || draft.name.trim().length === 0}
data-testid={`add-stage-submit-${ladder.projectWorkstreamId}`}
data-testid={`add-stage-submit-${ladder.workstreamId}`}
>
{add.isLoading ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
@ -335,7 +335,7 @@ export function StageLadderEditor({
size="sm"
disabled={busy}
onClick={() => setAdding(true)}
data-testid={`add-stage-${ladder.projectWorkstreamId}`}
data-testid={`add-stage-${ladder.workstreamId}`}
>
<Plus className="mr-1.5 h-4 w-4" />
Add stage

View file

@ -4,7 +4,7 @@ import { useState } from "react";
import { ChevronDown, ChevronUp, Flag, Lock, Trash2 } from "lucide-react";
import { Input } from "@/app/shadcn_components/ui/input";
import { DateInput } from "@/app/components/DateInput";
import type { LadderStage } from "@/app/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages/queries";
import type { LadderStage } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
import { IMPOSSIBLE_DATE_MESSAGE, isIsoDate } from "./dateEntry";
/** The two optional per-stage dates, as they are named on the wire. */

View file

@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Info } from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages/queries";
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
import { StageLadderEditor } from "./StageLadderEditor";
import { LadderOverridesPanel } from "./LadderOverridesPanel";
@ -40,10 +40,11 @@ export function stageLaddersQueryKey(projectId: string) {
async function fetchLadder(
projectId: string,
projectWorkstreamId: string,
/** The catalogue `workstream.id` — what the stages routes are keyed on. */
workstreamId: string,
): Promise<WorkstreamLadder> {
const res = await fetch(
`/api/projects/${projectId}/workstreams/${projectWorkstreamId}/stages`,
`/api/projects/${projectId}/workstreams/${workstreamId}/stages`,
);
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? "Couldn't load the stages");
@ -64,7 +65,7 @@ export function StagesSetup({
const router = useRouter();
const queryClient = useQueryClient();
const workstreamIds = initialLadders.map((l) => l.projectWorkstreamId);
const workstreamIds = initialLadders.map((l) => l.workstreamId);
const { data: ladders } = useQuery({
queryKey: stageLaddersQueryKey(projectId),
@ -98,7 +99,7 @@ export function StagesSetup({
<div className="mt-6 space-y-6">
{ladders.map((ladder) => (
<StageLadderEditor
key={ladder.projectWorkstreamId}
key={ladder.workstreamId}
projectId={projectId}
ladder={ladder}
canManage={canManage}

View file

@ -5,7 +5,7 @@ import { authorizeWorkstreamRequest } from "@/app/api/projects/[projectId]/works
import {
listProjectLadders,
seedDefaultLadders,
} from "@/app/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages/queries";
} from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
import { StagesSetup, type SetupEntryMode } from "./StagesSetup";
export const metadata = {