mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-02 12:58:24 +00:00
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:
parent
42c027a5c5
commit
ab29a246a4
13 changed files with 151 additions and 83 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -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 },
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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’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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue