feat(ara-projects): stage ladder CRUD for a Project Workstream (#411)

The ladder is v1's only lifecycle mechanism, so the guardrails are hard
rules rather than warnings: a ladder never drops below two stages, never
loses a stage a work order sits in, and never develops a hole in `order`.
Every decision is pure (`ladder.ts`) and every count is re-read inside the
same transaction that applies the outcome — the browser's counts signpost,
they never gate.

Dates cross the wire as YYYY-MM-DD only (ADR-0019); dd/mm/yyyy is rejected.
`current_stage_id` and `work_order.status` are never written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-23 12:39:29 +00:00
parent bf3f8aeb20
commit a57fc5d0d2
7 changed files with 1993 additions and 0 deletions

View file

@ -0,0 +1,292 @@
/**
* PATCH / DELETE `.../stages/[stageId]` (issue #411).
*
* Database mocked at the module boundary (`../queries` and the authz
* repository); no connection is opened. The blocked-delete path the one the
* acceptance criteria call out is exercised here end to end through the
* handler, with the rules themselves covered in `../ladder.test.ts`.
*/
import { describe, expect, it, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const {
mockGetServerSession,
mockLoadAuthzUser,
mockLoadProjectAuthzFacts,
mockFindLadder,
mockUpdateStage,
mockDeleteStage,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockFindLadder: vi.fn(),
mockUpdateStage: vi.fn(),
mockDeleteStage: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("@/app/repositories/projects/authzRepository", () => ({
loadAuthzUser: mockLoadAuthzUser,
loadProjectAuthzFacts: mockLoadProjectAuthzFacts,
}));
vi.mock("../queries", () => ({
findLadder: mockFindLadder,
updateStage: mockUpdateStage,
deleteStage: mockDeleteStage,
}));
import { DELETE, PATCH } from "./route";
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
const NO_DEPENDENTS = {
workOrders: 0,
evidenceRequirements: 0,
isCurrentStage: false,
};
const LADDER = {
projectWorkstreamId: "9",
workstreamId: "1",
workstreamName: "Windows",
isDefault: false,
stages: [
{
id: "100",
name: "Ordered",
order: 0,
startDate: "2026-09-03",
dueDate: "2026-12-01",
dependents: NO_DEPENDENTS,
},
{
id: "101",
name: "Closed",
order: 1,
startDate: null,
dueDate: null,
dependents: { ...NO_DEPENDENTS, workOrders: 4 },
},
],
};
function signedInAs(organisationIds: string[]) {
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
mockLoadAuthzUser.mockResolvedValue({
id: 7n,
email: "someone@landlord.example",
organisationIds,
});
}
function projectExists() {
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: false,
contractorOrganisationIds: [CONTRACTOR_ORG],
});
}
const params = (stageId = "100") => ({
params: Promise.resolve({
projectId: "5",
projectWorkstreamId: "9",
stageId,
}),
});
const url = (stageId = "100") =>
`http://localhost/api/projects/5/workstreams/9/stages/${stageId}`;
function patchRequest(body: unknown, stageId = "100") {
return new NextRequest(url(stageId), {
method: "PATCH",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
mockFindLadder.mockResolvedValue(LADDER);
mockUpdateStage.mockResolvedValue({ kind: "updated" });
mockDeleteStage.mockResolvedValue({ kind: "deleted" });
});
describe("PATCH", () => {
beforeEach(() => {
signedInAs([CLIENT_ORG]);
projectExists();
});
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(401);
});
it("403s a caller who may view but not manage", async () => {
signedInAs([CONTRACTOR_ORG]);
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(403);
expect(mockUpdateStage).not.toHaveBeenCalled();
});
it("renames a stage", async () => {
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ stageId: "100", updated: true });
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { name: "On site" });
});
it("sets the optional dates", async () => {
const res = await PATCH(
patchRequest({ startDate: "2026-09-03", dueDate: "2026-12-01" }),
params("101"),
);
expect(res.status).toBe(200);
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 101n, {
startDate: "2026-09-03",
dueDate: "2026-12-01",
});
});
it("clears a date with null", async () => {
const res = await PATCH(patchRequest({ dueDate: null }), params());
expect(res.status).toBe(200);
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { dueDate: null });
});
it("treats an empty date string as a clear", async () => {
const res = await PATCH(patchRequest({ startDate: "" }), params());
expect(res.status).toBe(200);
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { startDate: null });
});
it("rejects dd/mm/yyyy on the wire", async () => {
const res = await PATCH(patchRequest({ dueDate: "01/12/2026" }), params());
expect(res.status).toBe(400);
expect(mockUpdateStage).not.toHaveBeenCalled();
});
it("checks a new start date against the stored due date", async () => {
// Stage 100 is due 2026-12-01; moving its start past that is rejected even
// though the request carries only one end of the range.
const res = await PATCH(patchRequest({ startDate: "2027-01-01" }), params());
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("before the start date");
expect(mockUpdateStage).not.toHaveBeenCalled();
});
it("allows a start date that clears the stored due date", async () => {
const res = await PATCH(patchRequest({ startDate: "2026-11-30" }), params());
expect(res.status).toBe(200);
});
it("400s an empty patch", async () => {
const res = await PATCH(patchRequest({}), params());
expect(res.status).toBe(400);
expect(mockUpdateStage).not.toHaveBeenCalled();
});
it("ignores any attempt to set status or order", async () => {
// Neither column is this issue's to write (ADR-0021): unknown keys are
// dropped by the schema, so they never reach the update.
const res = await PATCH(
patchRequest({ name: "On site", status: "complete", order: 0 }),
params(),
);
expect(res.status).toBe(200);
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { name: "On site" });
});
it("404s a stage that is not in this workstream's ladder", async () => {
const res = await PATCH(patchRequest({ name: "On site" }, "999"), params("999"));
expect(res.status).toBe(404);
expect(mockUpdateStage).not.toHaveBeenCalled();
});
it("404s a workstream of another project", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(404);
expect(mockUpdateStage).not.toHaveBeenCalled();
});
it("passes a rejected name back as a 400 with its reason", async () => {
mockUpdateStage.mockResolvedValue({
kind: "rejected",
reason: 'This workstream already has a "Closed" stage.',
});
const res = await PATCH(patchRequest({ name: "Closed" }), params());
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("already has");
});
});
describe("DELETE", () => {
beforeEach(() => {
signedInAs([CLIENT_ORG]);
projectExists();
});
it("403s a caller who may view but not manage", async () => {
signedInAs([CONTRACTOR_ORG]);
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(403);
expect(mockDeleteStage).not.toHaveBeenCalled();
});
it("deletes a stage", async () => {
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ stageId: "100", deleted: true });
expect(mockDeleteStage).toHaveBeenCalledWith(9n, 100n);
});
it("409s a blocked delete, flagged so the UI can tell it from a failure", async () => {
mockDeleteStage.mockResolvedValue({
kind: "blocked",
reason:
"4 work orders are in this stage. Move them to another stage before removing it.",
});
const res = await DELETE(new NextRequest(url("101")), params("101"));
expect(res.status).toBe(409);
expect(await res.json()).toEqual({
error:
"4 work orders are in this stage. Move them to another stage before removing it.",
blocked: true,
});
});
it("404s a stage that has already gone", async () => {
mockDeleteStage.mockResolvedValue({ kind: "not-found" });
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(404);
});
it("400s a non-numeric stage id", async () => {
const res = await DELETE(new NextRequest(url("abc")), params("abc"));
expect(res.status).toBe(400);
expect(mockDeleteStage).not.toHaveBeenCalled();
});
it("404s a workstream of another project without writing", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(404);
expect(mockDeleteStage).not.toHaveBeenCalled();
});
it("500s when the delete fails", async () => {
mockDeleteStage.mockRejectedValue(new Error("boom"));
vi.spyOn(console, "error").mockImplementation(() => {});
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "Couldn't remove the stage" });
});
});

View file

@ -0,0 +1,163 @@
/**
* `.../workstreams/[projectWorkstreamId]/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.
*
* The delete guardrails are the server's decision, always: the counts a
* browser is holding can be seconds out of date, and a Work order raised into
* a stage in the meantime must still block the delete. The client's job is to
* render the 409 it gets back, not to pre-empt it the same division of
* labour the workstream deselect uses (#410).
*
* Unlike that deselect there is no `?confirm=true`: every refusal here is a
* hard rule, not a warning, because nothing it protects belongs to this
* screen. See `../ladder.ts`.
*/
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { authorizeWorkstreamRequest } from "../../../authorize";
import { checkStageDates } from "../ladder";
import { deleteStage, findLadder, updateStage } from "../queries";
type Params = {
params: Promise<{
projectId: string;
projectWorkstreamId: string;
stageId: string;
}>;
};
/**
* `YYYY-MM-DD` on the wire, always (ADR-0019). `null` clears the date, an
* absent key leaves it untouched, and `""` what a half-cleared field sends
* means the same as `null`.
*/
const optionalIsoDate = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Dates must be YYYY-MM-DD")
.or(z.literal(""))
.nullable()
.transform((value) => (value ? value : null));
const patchSchema = z
.object({
name: z.string().optional(),
startDate: optionalIsoDate.optional(),
dueDate: optionalIsoDate.optional(),
})
.refine(
(patch) => Object.keys(patch).length > 0,
"Nothing to update",
);
function parseId(raw: string): bigint | null {
return /^\d+$/.test(raw) ? BigInt(raw) : 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 auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseId(projectWorkstreamId);
const id = parseId(stageId);
if (workstreamId === null || id === null) {
return NextResponse.json({ error: "Invalid stage" }, { status: 400 });
}
let body: z.infer<typeof patchSchema>;
try {
body = patchSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
// Scopes the stage to the project in the path, and gives the current dates —
// a patch that sets only one end of the range still has to be checked
// against the other end as stored.
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
const current = ladder.stages.find((stage) => stage.id === stageId);
if (!current) {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
}
const dates = checkStageDates(
body.startDate === undefined ? current.startDate : body.startDate,
body.dueDate === undefined ? current.dueDate : body.dueDate,
);
if (!dates.ok) {
return NextResponse.json({ error: dates.error }, { status: 400 });
}
try {
const result = await updateStage(workstreamId, id, body);
if (result.kind === "not-found") {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
}
if (result.kind === "rejected") {
return NextResponse.json({ error: result.reason }, { status: 400 });
}
return NextResponse.json({ stageId, updated: true });
} catch (err) {
console.error("PATCH .../stages/[stageId] failed:", err);
return NextResponse.json(
{ error: "Couldn't update the stage" },
{ status: 500 },
);
}
}
/** 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 auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseId(projectWorkstreamId);
const id = parseId(stageId);
if (workstreamId === null || id === null) {
return NextResponse.json({ error: "Invalid stage" }, { status: 400 });
}
if (!(await findLadder(auth.projectId, workstreamId))) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
try {
const result = await deleteStage(workstreamId, id);
if (result.kind === "not-found") {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
}
// 409, and `blocked` alongside the message, so the grid can tell a refusal
// from a transport failure without parsing the sentence.
if (result.kind === "blocked") {
return NextResponse.json(
{ error: result.reason, blocked: true },
{ status: 409 },
);
}
return NextResponse.json({ stageId, deleted: true });
} catch (err) {
console.error("DELETE .../stages/[stageId] failed:", err);
return NextResponse.json(
{ error: "Couldn't remove the stage" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,240 @@
/**
* The stage-ladder rules (issue #411). Pure: no database, no mocks needed.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_LADDER,
MIN_STAGES,
NO_STAGE_DEPENDENTS,
checkReorder,
checkStageDates,
checkStageName,
contiguousOrders,
decideDeleteStage,
isDefaultLadder,
moveStage,
type StageDependents,
} from "./ladder";
const dependents = (patch: Partial<StageDependents> = {}): StageDependents => ({
...NO_STAGE_DEPENDENTS,
...patch,
});
describe("DEFAULT_LADDER", () => {
it("is the v1 ladder from CONTEXT.md, in order", () => {
expect([...DEFAULT_LADDER]).toEqual([
"Ordered",
"In progress",
"Completed",
"Charged",
"Closed",
]);
});
});
describe("decideDeleteStage", () => {
it("allows a delete from a ladder nothing points at", () => {
expect(decideDeleteStage(5, dependents())).toEqual({ kind: "allowed" });
});
it("blocks a delete that would leave fewer than two stages", () => {
const decision = decideDeleteStage(MIN_STAGES, dependents());
expect(decision.kind).toBe("blocked");
expect(decision.kind === "blocked" && decision.reason).toContain(
"at least 2 stages",
);
});
it("blocks a delete of a stage with work orders in it", () => {
const decision = decideDeleteStage(5, dependents({ workOrders: 3 }));
expect(decision).toEqual({
kind: "blocked",
reason:
"3 work orders are in this stage. Move them to another stage before removing it.",
});
});
it("says 'work order' in the singular for one", () => {
const decision = decideDeleteStage(5, dependents({ workOrders: 1 }));
expect(decision.kind === "blocked" && decision.reason).toBe(
"1 work order is in this stage. Move them to another stage before removing it.",
);
});
it("blocks a delete of a stage an evidence requirement points at", () => {
const decision = decideDeleteStage(
5,
dependents({ evidenceRequirements: 1 }),
);
expect(decision.kind === "blocked" && decision.reason).toContain(
"1 evidence requirement expects",
);
});
it("blocks a delete of the workstream's current stage", () => {
const decision = decideDeleteStage(5, dependents({ isCurrentStage: true }));
expect(decision.kind === "blocked" && decision.reason).toContain(
"current stage",
);
});
it("reports the minimum-stages rule ahead of what is in the stage", () => {
// Both rules apply; the ladder's shape is the simpler thing to fix.
const decision = decideDeleteStage(2, dependents({ workOrders: 4 }));
expect(decision.kind === "blocked" && decision.reason).toContain(
"at least 2 stages",
);
});
it("has no confirmation escape hatch — a block is final", () => {
// Guards the shape of the decision type: unlike the deselect cascade
// (#410) there is no `needs-confirmation` outcome to override.
expect(
decideDeleteStage(5, dependents({ workOrders: 1 })).kind,
).toBe("blocked");
});
});
describe("isDefaultLadder", () => {
it("recognises the seeded ladder", () => {
expect(isDefaultLadder([...DEFAULT_LADDER])).toBe(true);
});
it("ignores case and surrounding whitespace", () => {
expect(
isDefaultLadder(["ordered", " In progress", "COMPLETED", "Charged", "Closed"]),
).toBe(true);
});
it("is false once a stage is renamed", () => {
expect(
isDefaultLadder(["Ordered", "On site", "Completed", "Charged", "Closed"]),
).toBe(false);
});
it("is false once the ladder is reordered", () => {
expect(
isDefaultLadder(["In progress", "Ordered", "Completed", "Charged", "Closed"]),
).toBe(false);
});
it("is false when a stage is added or removed", () => {
expect(isDefaultLadder([...DEFAULT_LADDER, "Snagging"])).toBe(false);
expect(isDefaultLadder(DEFAULT_LADDER.slice(0, 4))).toBe(false);
});
it("is false for a workstream with no stages", () => {
expect(isDefaultLadder([])).toBe(false);
});
});
describe("checkStageName", () => {
it("trims and collapses whitespace", () => {
expect(checkStageName(" On site ", [])).toEqual({
ok: true,
name: "On site",
});
});
it("rejects a blank name", () => {
expect(checkStageName(" ", []).ok).toBe(false);
});
it("rejects a name longer than 60 characters", () => {
expect(checkStageName("x".repeat(61), []).ok).toBe(false);
expect(checkStageName("x".repeat(60), []).ok).toBe(true);
});
it("rejects a duplicate, ignoring case", () => {
const check = checkStageName("ordered", ["Ordered", "Closed"]);
expect(check.ok).toBe(false);
expect(!check.ok && check.error).toContain("already has");
});
it("lets a stage keep its own name on rename", () => {
// The caller passes the *other* stages' names, so this is not a clash.
expect(checkStageName("Ordered", ["Closed"]).ok).toBe(true);
});
});
describe("checkStageDates", () => {
it("accepts a stage with no dates", () => {
expect(checkStageDates(null, null)).toEqual({ ok: true });
});
it("accepts either date on its own", () => {
expect(checkStageDates("2026-09-03", null).ok).toBe(true);
expect(checkStageDates(null, "2026-09-03").ok).toBe(true);
});
it("accepts a due date on or after the start date", () => {
expect(checkStageDates("2026-09-03", "2026-09-03").ok).toBe(true);
expect(checkStageDates("2026-09-03", "2026-12-01").ok).toBe(true);
});
it("rejects a due date before the start date", () => {
// 03/09/2026 is 3 September and 09/03/2026 would be 9 March — the ISO
// strings compare correctly whatever a browser's locale renders.
const check = checkStageDates("2026-09-03", "2026-03-09");
expect(check.ok).toBe(false);
expect(!check.ok && check.error).toContain("before the start date");
});
});
describe("checkReorder", () => {
it("accepts a permutation of the ladder", () => {
expect(checkReorder(["1", "2", "3"], ["3", "1", "2"])).toEqual({
ok: true,
ids: ["3", "1", "2"],
});
});
it("rejects a list that is missing a stage", () => {
expect(checkReorder(["1", "2", "3"], ["1", "2"]).ok).toBe(false);
});
it("rejects a list naming an unknown stage", () => {
expect(checkReorder(["1", "2", "3"], ["1", "2", "9"]).ok).toBe(false);
});
it("rejects a list repeating a stage", () => {
expect(checkReorder(["1", "2", "3"], ["1", "1", "2"]).ok).toBe(false);
});
it("explains a mismatch as a stale list, not a bad request", () => {
const check = checkReorder(["1", "2"], ["1"]);
expect(!check.ok && check.error).toContain("Reload");
});
});
describe("contiguousOrders", () => {
it("numbers from 0 with no gaps", () => {
expect(contiguousOrders(["7", "4", "9"])).toEqual([
{ id: "7", order: 0 },
{ id: "4", order: 1 },
{ id: "9", order: 2 },
]);
});
});
describe("moveStage", () => {
it("swaps a stage with the one above it", () => {
expect(moveStage(["a", "b", "c"], 2, "up")).toEqual(["a", "c", "b"]);
});
it("swaps a stage with the one below it", () => {
expect(moveStage(["a", "b", "c"], 0, "down")).toEqual(["b", "a", "c"]);
});
it("is a no-op at either end", () => {
expect(moveStage(["a", "b"], 0, "up")).toEqual(["a", "b"]);
expect(moveStage(["a", "b"], 1, "down")).toEqual(["a", "b"]);
});
it("does not mutate the input", () => {
const ids = ["a", "b"];
moveStage(ids, 0, "down");
expect(ids).toEqual(["a", "b"]);
});
});

View file

@ -0,0 +1,254 @@
/**
* Stage-ladder rules for a Project Workstream (issue #411).
*
* Deliberately **pure** no database client, no React so the route
* handlers, the unit tests and the browser bundle share one copy of the rules,
* the way `../../cascade.ts` does for the deselect cascade. Every count these
* functions decide from is loaded in `./queries`.
*
* The ladder is the *only* lifecycle mechanism in v1 (CONTEXT.md, **Stage**):
* a Work order's position is its stage FK, and the terminal stage is simply
* the one with the highest `order`. That is why the guardrails here are strict
* a ladder that loses a stage under a Work order, or whose `order` values
* develop a hole, is a workflow that has silently changed shape.
*/
/**
* The ladder every Project Workstream starts with, in order seeded once,
* then owned by the user (ADR-0021).
*
* The wireframe's "New / Active / Active / Active / Complete" is placeholder
* junk and is deliberately not what we seed; this is the v1 default named in
* CONTEXT.md and issue #411.
*/
export const DEFAULT_LADDER = [
"Ordered",
"In progress",
"Completed",
"Charged",
"Closed",
] as const;
/**
* A ladder with one stage is not a workflow nothing can move. Two is the
* smallest ladder with a start and a terminal stage.
*/
export const MIN_STAGES = 2;
/** Long enough for "Awaiting client sign-off", short enough to render. */
export const MAX_STAGE_NAME_LENGTH = 60;
/** What already points at one stage, and therefore what a delete would break. */
export interface StageDependents {
/** `work_order.project_workstream_stage_id` rows sitting in this stage. */
workOrders: number;
/** Evidence requirements (#412) narrowed to this stage. */
evidenceRequirements: number;
/** Whether `project_workstream.current_stage_id` points here. */
isCurrentStage: boolean;
}
/** A stage nothing points at. */
export const NO_STAGE_DEPENDENTS: StageDependents = {
workOrders: 0,
evidenceRequirements: 0,
isCurrentStage: false,
};
export type DeleteStageDecision =
| { kind: "blocked"; reason: string }
| { kind: "allowed" };
/**
* Whether a stage may be deleted from a ladder of `stageCount` stages.
*
* Unlike the workstream deselect, there is no `confirm` escape hatch: each of
* these is a hard rule, because none of what they protect is this screen's to
* throw away. Work orders and evidence requirements are other features' rows,
* `current_stage_id` is a column #411 never writes (ADR-0021), and a
* one-stage ladder is not a workflow.
*
* Checked shape-first (is the ladder still a ladder afterwards?) then
* contents, so the reason a user sees names the simplest thing that is wrong.
*/
export function decideDeleteStage(
stageCount: number,
dependents: StageDependents,
): DeleteStageDecision {
if (stageCount <= MIN_STAGES) {
return {
kind: "blocked",
reason:
`A workstream needs at least ${MIN_STAGES} stages. ` +
`Add another stage before removing this one.`,
};
}
if (dependents.workOrders > 0) {
const plural = dependents.workOrders === 1 ? "work order" : "work orders";
return {
kind: "blocked",
reason:
`${dependents.workOrders} ${plural} ${dependents.workOrders === 1 ? "is" : "are"} in this stage. ` +
`Move them to another stage before removing it.`,
};
}
if (dependents.evidenceRequirements > 0) {
const n = dependents.evidenceRequirements;
return {
kind: "blocked",
reason:
`${n} evidence ${n === 1 ? "requirement expects" : "requirements expect"} a document at this stage. ` +
`Remove ${n === 1 ? "it" : "them"} first.`,
};
}
if (dependents.isCurrentStage) {
return {
kind: "blocked",
reason:
"This stage is the workstream's current stage, so it can't be removed.",
};
}
return { kind: "allowed" };
}
/**
* Whether a ladder is still the seeded default same names, same order.
*
* Drives the "Default / Custom" badge on the overrides panel. Compared
* case-insensitively on trimmed names, so re-typing "ordered" over "Ordered"
* does not read as an override the user did not make.
*/
export function isDefaultLadder(names: readonly string[]): boolean {
if (names.length !== DEFAULT_LADDER.length) return false;
return names.every(
(name, i) =>
name.trim().toLowerCase() === DEFAULT_LADDER[i].trim().toLowerCase(),
);
}
export type StageNameCheck =
| { ok: true; name: string }
| { ok: false; error: string };
/**
* Validate and normalise a stage name against the names already in the ladder.
*
* `otherNames` is every *other* stage's name a rename must exclude the stage
* being renamed, or renaming "Charged" to "Charged" would collide with itself.
* Duplicates are rejected case-insensitively: two stages a user cannot tell
* apart make the ladder unreadable, and stage names are how work orders are
* talked about.
*/
export function checkStageName(
raw: string,
otherNames: readonly string[],
): StageNameCheck {
const name = raw.trim().replace(/\s+/g, " ");
if (name.length === 0) {
return { ok: false, error: "Give the stage a name." };
}
if (name.length > MAX_STAGE_NAME_LENGTH) {
return {
ok: false,
error: `Stage names can be at most ${MAX_STAGE_NAME_LENGTH} characters.`,
};
}
const clash = otherNames.some(
(other) => other.trim().toLowerCase() === name.toLowerCase(),
);
if (clash) {
return { ok: false, error: `This workstream already has a "${name}" stage.` };
}
return { ok: true, name };
}
export type StageDatesCheck = { ok: true } | { ok: false; error: string };
/**
* Validate the optional per-stage dates.
*
* Both are `YYYY-MM-DD` the only format that crosses a boundary (ADR-0019);
* what the user reads and types is `dd/mm/yyyy`, and `DateInput` converts at
* the field's edge. ISO dates sort lexicographically, so the ordering check is
* a string comparison with no `Date` and therefore no timezone to get wrong.
*/
export function checkStageDates(
startDate: string | null,
dueDate: string | null,
): StageDatesCheck {
if (startDate && dueDate && dueDate < startDate) {
return { ok: false, error: "The due date can't be before the start date." };
}
return { ok: true };
}
export type ReorderCheck =
| { ok: true; ids: string[] }
| { ok: false; error: string };
/**
* Check that a requested order is a permutation of the ladder it claims to
* reorder same ids, each exactly once.
*
* A reorder that dropped or invented an id would renumber a partial ladder and
* leave a hole in `order`, so the request is refused rather than repaired. The
* caller compares against ids re-read inside the same transaction, which is
* also what makes this the stale-client check: a ladder edited in another tab
* no longer matches, so the reorder is rejected instead of applied to a shape
* the user was not looking at.
*/
export function checkReorder(
currentIds: readonly string[],
requestedIds: readonly string[],
): ReorderCheck {
const stale = {
ok: false as const,
error: "The stage list has changed. Reload and try again.",
};
if (requestedIds.length !== currentIds.length) return stale;
if (new Set(requestedIds).size !== requestedIds.length) return stale;
const current = new Set(currentIds);
if (!requestedIds.every((id) => current.has(id))) return stale;
return { ok: true, ids: [...requestedIds] };
}
/**
* The `order` each id should carry: contiguous from 0, in the given sequence.
*
* Every write path that changes the shape of a ladder add, delete, reorder
* ends here, so `order` is contiguous by construction rather than by repair.
*/
export function contiguousOrders(
ids: readonly string[],
): Array<{ id: string; order: number }> {
return ids.map((id, order) => ({ id, order }));
}
/**
* Move the stage at `index` one place towards the start or end.
*
* Returns the ids unchanged when the move would fall off either end, so the
* caller can treat "already at the top" as a no-op rather than an error.
*/
export function moveStage(
ids: readonly string[],
index: number,
direction: "up" | "down",
): string[] {
const target = direction === "up" ? index - 1 : index + 1;
if (index < 0 || index >= ids.length) return [...ids];
if (target < 0 || target >= ids.length) return [...ids];
const next = [...ids];
[next[index], next[target]] = [next[target], next[index]];
return next;
}

View file

@ -0,0 +1,527 @@
/**
* Persistence for the stage-configuration setup screen (issue #411).
*
* The route handlers and the server-rendered page both read through here, so
* the two always agree on shape the page paints first, the handlers serve
* every refetch after it. Same split as `../../queries.ts` for #410.
*
* Two columns are never written from this module, by decision (ADR-0021):
* - `project_workstream.current_stage_id` deferred to #426; v1 derives a
* workstream's position from its Work orders' stage FKs.
* - `work_order.status` the stage FK is the only progress mechanism.
*
* `project_workstream_stage.status` is a third case and the awkward one: it is
* `NOT NULL` with no database default, so a row cannot be inserted without a
* value. Inserts therefore write the placeholder constant below, once, and no
* update path ever touches the column again. See ADR-0021.
*/
import { db } from "@/app/db/db";
import { and, eq, inArray, sql } from "drizzle-orm";
import {
projectWorkstream,
projectWorkstreamEvidenceRequirement,
projectWorkstreamStage,
workOrder,
workstream,
} from "@/app/db/schema/projects/projects";
import {
DEFAULT_LADDER,
checkReorder,
checkStageName,
contiguousOrders,
decideDeleteStage,
isDefaultLadder,
type StageDependents,
} from "./ladder";
/**
* The value written into the `NOT NULL` `status` column on insert.
*
* Not a lifecycle state and not read by anything the stage's meaning is its
* position in the ladder. Matches what `seed-projects-dashboard.ts` already
* writes, so existing rows and new ones agree. When #426 gives the column a
* real value set, that issue owns the backfill.
*/
export const PLACEHOLDER_STAGE_STATUS = "active";
/** One stage as the API and the page render it. */
export interface LadderStage {
/** `project_workstream_stage.id`, serialised — bigints do not survive JSON. */
id: string;
name: string;
/** Contiguous from 0 within its ladder. */
order: number;
/** `YYYY-MM-DD`, or null. Optional per issue #411. */
startDate: string | null;
dueDate: string | null;
/** What points at this stage — drives the delete guardrails' explanations. */
dependents: StageDependents;
}
/** One Project Workstream's whole ladder. */
export interface WorkstreamLadder {
/** `project_workstream.id` — the id the CRUD routes are keyed on. */
projectWorkstreamId: string;
/** `workstream.id`, the reference-data row. */
workstreamId: string;
workstreamName: string;
stages: LadderStage[];
/** True while the ladder is still the seeded default, names and order. */
isDefault: boolean;
}
/** One row of the stage read, before it is serialised for the wire. */
type StageRow = {
id: bigint;
projectWorkstreamId: bigint;
name: string;
order: number;
startDate: string | null;
dueDate: string | null;
workOrders: number;
evidenceRequirements: number;
isCurrentStage: boolean;
};
/**
* Every stage of the Project Workstreams matching `where`, with the counts the
* delete guardrails need.
*
* One round trip: the two child tables are left-joined and counted with
* `count(distinct)`, so the join fan-out between them does not inflate either
* number. `current_stage_id` is read (never written) to spot the stage a
* workstream points at.
*/
async function loadStageRows(
where: ReturnType<typeof and>,
): Promise<StageRow[]> {
return db
.select({
id: projectWorkstreamStage.id,
projectWorkstreamId: projectWorkstreamStage.projectWorkstreamId,
name: projectWorkstreamStage.name,
order: projectWorkstreamStage.order,
startDate: projectWorkstreamStage.startDate,
dueDate: projectWorkstreamStage.dueDate,
workOrders: sql<number>`count(distinct ${workOrder.id})::int`,
evidenceRequirements: sql<number>`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`,
isCurrentStage: sql<boolean>`coalesce(bool_or(${projectWorkstream.currentStageId} = ${projectWorkstreamStage.id}), false)`,
})
.from(projectWorkstreamStage)
.innerJoin(
projectWorkstream,
eq(projectWorkstream.id, projectWorkstreamStage.projectWorkstreamId),
)
.leftJoin(
workOrder,
eq(workOrder.projectWorkstreamStageId, projectWorkstreamStage.id),
)
.leftJoin(
projectWorkstreamEvidenceRequirement,
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
projectWorkstreamStage.id,
),
)
.where(where)
.groupBy(projectWorkstreamStage.id)
.orderBy(projectWorkstreamStage.order, projectWorkstreamStage.id);
}
function toLadderStage(row: StageRow): LadderStage {
return {
id: row.id.toString(),
name: row.name,
order: row.order,
startDate: row.startDate,
dueDate: row.dueDate,
dependents: {
workOrders: row.workOrders,
evidenceRequirements: row.evidenceRequirements,
isCurrentStage: row.isCurrentStage,
},
};
}
function toLadder(
workstreamRow: {
projectWorkstreamId: bigint;
workstreamId: bigint;
workstreamName: string;
},
rows: StageRow[],
): WorkstreamLadder {
const stages = rows.map(toLadderStage);
return {
projectWorkstreamId: workstreamRow.projectWorkstreamId.toString(),
workstreamId: workstreamRow.workstreamId.toString(),
workstreamName: workstreamRow.workstreamName,
stages,
isDefault: isDefaultLadder(stages.map((s) => s.name)),
};
}
/** The Project Workstreams a project has selected, catalogue name included. */
async function loadSelectedWorkstreams(
projectId: bigint,
projectWorkstreamId?: bigint,
) {
return db
.select({
projectWorkstreamId: projectWorkstream.id,
workstreamId: projectWorkstream.workstreamId,
workstreamName: workstream.name,
})
.from(projectWorkstream)
.innerJoin(workstream, eq(workstream.id, projectWorkstream.workstreamId))
.where(
projectWorkstreamId === undefined
? eq(projectWorkstream.projectId, projectId)
: and(
eq(projectWorkstream.projectId, projectId),
eq(projectWorkstream.id, projectWorkstreamId),
),
)
.orderBy(projectWorkstream.id);
}
/**
* Every selected workstream's ladder for one project what the setup screen
* renders on first paint.
*
* A workstream with no stages comes back with an empty `stages` array rather
* than being omitted: that is precisely the state the import readiness gate
* (#414) reports as incomplete, so the screen must show it.
*/
export async function listProjectLadders(
projectId: bigint,
): Promise<WorkstreamLadder[]> {
const workstreams = await loadSelectedWorkstreams(projectId);
if (workstreams.length === 0) return [];
const rows = await loadStageRows(eq(projectWorkstream.projectId, projectId));
const byWorkstream = new Map<string, StageRow[]>();
for (const row of rows) {
const key = row.projectWorkstreamId.toString();
const bucket = byWorkstream.get(key);
if (bucket) bucket.push(row);
else byWorkstream.set(key, [row]);
}
return workstreams.map((w) =>
toLadder(w, byWorkstream.get(w.projectWorkstreamId.toString()) ?? []),
);
}
/**
* One Project Workstream's ladder, or null when `projectWorkstreamId` is not a
* workstream of `projectId`.
*
* 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.
*/
export async function findLadder(
projectId: bigint,
projectWorkstreamId: bigint,
): Promise<WorkstreamLadder | null> {
const [workstreamRow] = await loadSelectedWorkstreams(
projectId,
projectWorkstreamId,
);
if (!workstreamRow) return null;
const rows = await loadStageRows(
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId),
);
return toLadder(workstreamRow, rows);
}
/**
* Seed the default ladder for every workstream of `projectId` that has none.
*
* Idempotent, and idempotent under concurrency: the workstream rows are locked
* `FOR UPDATE` before their stage counts are read, so two simultaneous visits
* to the setup screen cannot both find "no stages" and both insert. The second
* transaction blocks, re-reads, and finds the ladder already there. That lock
* is what makes "seeded exactly once per workstream" true rather than likely.
*
* Returns the ids seeded, so the caller can tell a first visit from a revisit.
*/
export async function seedDefaultLadders(
projectId: bigint,
): Promise<string[]> {
return db.transaction(async (tx) => {
const workstreams = await tx
.select({ id: projectWorkstream.id })
.from(projectWorkstream)
.where(eq(projectWorkstream.projectId, projectId))
.for("update");
if (workstreams.length === 0) return [];
const withStages = await tx
.select({ id: projectWorkstreamStage.projectWorkstreamId })
.from(projectWorkstreamStage)
.where(
inArray(
projectWorkstreamStage.projectWorkstreamId,
workstreams.map((w) => w.id),
),
)
.groupBy(projectWorkstreamStage.projectWorkstreamId);
const seeded = new Set(withStages.map((row) => row.id.toString()));
const empty = workstreams.filter((w) => !seeded.has(w.id.toString()));
if (empty.length === 0) return [];
await tx.insert(projectWorkstreamStage).values(
empty.flatMap((w) =>
DEFAULT_LADDER.map((name, order) => ({
projectWorkstreamId: w.id,
name,
order,
status: PLACEHOLDER_STAGE_STATUS,
})),
),
);
return empty.map((w) => w.id.toString());
});
}
/** The stages of one workstream, locked for the rest of the transaction. */
async function lockedStages(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
projectWorkstreamId: bigint,
) {
return tx
.select({
id: projectWorkstreamStage.id,
name: projectWorkstreamStage.name,
order: projectWorkstreamStage.order,
})
.from(projectWorkstreamStage)
.where(eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId))
.orderBy(projectWorkstreamStage.order, projectWorkstreamStage.id)
.for("update");
}
/** Rewrite `order` so the given sequence is contiguous from 0. */
async function writeOrders(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
ids: string[],
): Promise<void> {
for (const { id, order } of contiguousOrders(ids)) {
await tx
.update(projectWorkstreamStage)
.set({ order })
.where(eq(projectWorkstreamStage.id, BigInt(id)));
}
}
export type AddStageResult =
| { kind: "added"; stageId: string }
| { kind: "rejected"; reason: string };
/**
* Append a stage to the end of a ladder.
*
* New stages land last because that is the only position that cannot change
* what the existing ladder means inserting in the middle would silently
* renumber stages that Work orders already sit in. Reordering afterwards is
* one click away, and is an explicit act.
*
* The name is re-checked against names re-read inside the transaction, so two
* tabs adding "Snagging" at once cannot both succeed.
*/
export async function addStage(
projectWorkstreamId: bigint,
stage: { name: string; startDate: string | null; dueDate: string | null },
): Promise<AddStageResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
const check = checkStageName(
stage.name,
existing.map((s) => s.name),
);
if (!check.ok) return { kind: "rejected", reason: check.error };
const [inserted] = await tx
.insert(projectWorkstreamStage)
.values({
projectWorkstreamId,
name: check.name,
order: existing.length,
startDate: stage.startDate,
dueDate: stage.dueDate,
status: PLACEHOLDER_STAGE_STATUS,
})
.returning({ id: projectWorkstreamStage.id });
// The append is only contiguous if the ladder already was; a ladder that
// arrived here with a hole is straightened rather than extended crooked.
await writeOrders(tx, [
...existing.map((s) => s.id.toString()),
inserted.id.toString(),
]);
return { kind: "added", stageId: inserted.id.toString() };
});
}
export type UpdateStageResult =
| { kind: "updated" }
| { kind: "not-found" }
| { kind: "rejected"; reason: string };
/**
* Rename a stage and/or set its optional dates.
*
* Only the fields present in `patch` are written a rename never clears a
* date, and a date change never renames. `status`, `status_description` and
* `order` are not writable here: order changes go through `reorderStages`, and
* the status columns are not this issue's to write (ADR-0021).
*/
export async function updateStage(
projectWorkstreamId: bigint,
stageId: bigint,
patch: {
name?: string;
startDate?: string | null;
dueDate?: string | null;
},
): Promise<UpdateStageResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
const target = existing.find((s) => s.id === stageId);
if (!target) return { kind: "not-found" };
const values: {
name?: string;
startDate?: string | null;
dueDate?: string | null;
} = {};
if (patch.name !== undefined) {
const check = checkStageName(
patch.name,
existing.filter((s) => s.id !== stageId).map((s) => s.name),
);
if (!check.ok) return { kind: "rejected", reason: check.error };
values.name = check.name;
}
if (patch.startDate !== undefined) values.startDate = patch.startDate;
if (patch.dueDate !== undefined) values.dueDate = patch.dueDate;
if (Object.keys(values).length > 0) {
await tx
.update(projectWorkstreamStage)
.set(values)
.where(eq(projectWorkstreamStage.id, stageId));
}
return { kind: "updated" };
});
}
export type DeleteStageResult =
| { kind: "deleted" }
| { kind: "not-found" }
| { kind: "blocked"; reason: string };
/**
* Delete a stage, then close the gap its `order` left behind.
*
* The guardrails are evaluated against counts re-read **inside** the
* transaction with the ladder locked, not against whatever the client last
* saw: a Work order raised into this stage a second ago must block the delete,
* and the browser cannot know that. `decideDeleteStage` makes the decision;
* this function only gathers the facts and applies the outcome.
*/
export async function deleteStage(
projectWorkstreamId: bigint,
stageId: bigint,
): Promise<DeleteStageResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
if (!existing.some((s) => s.id === stageId)) return { kind: "not-found" };
const [counts] = await tx
.select({
workOrders: sql<number>`count(distinct ${workOrder.id})::int`,
evidenceRequirements: sql<number>`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`,
isCurrentStage: sql<boolean>`coalesce(bool_or(${projectWorkstream.currentStageId} = ${projectWorkstreamStage.id}), false)`,
})
.from(projectWorkstreamStage)
.innerJoin(
projectWorkstream,
eq(projectWorkstream.id, projectWorkstreamStage.projectWorkstreamId),
)
.leftJoin(
workOrder,
eq(workOrder.projectWorkstreamStageId, projectWorkstreamStage.id),
)
.leftJoin(
projectWorkstreamEvidenceRequirement,
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
projectWorkstreamStage.id,
),
)
.where(eq(projectWorkstreamStage.id, stageId))
.groupBy(projectWorkstreamStage.id);
const decision = decideDeleteStage(existing.length, {
workOrders: counts?.workOrders ?? 0,
evidenceRequirements: counts?.evidenceRequirements ?? 0,
isCurrentStage: counts?.isCurrentStage ?? false,
});
if (decision.kind === "blocked") {
return { kind: "blocked", reason: decision.reason };
}
await tx
.delete(projectWorkstreamStage)
.where(eq(projectWorkstreamStage.id, stageId));
await writeOrders(
tx,
existing.filter((s) => s.id !== stageId).map((s) => s.id.toString()),
);
return { kind: "deleted" };
});
}
export type ReorderStagesResult =
| { kind: "reordered" }
| { kind: "rejected"; reason: string };
/**
* Rewrite a ladder's `order` to the sequence the client asked for.
*
* The request must name every stage of the ladder exactly once; a request that
* does not is refused as stale rather than applied to a shape the user was not
* looking at. The comparison is against ids re-read under the lock, which is
* what makes the staleness check meaningful.
*/
export async function reorderStages(
projectWorkstreamId: bigint,
requestedIds: string[],
): Promise<ReorderStagesResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
const check = checkReorder(
existing.map((s) => s.id.toString()),
requestedIds,
);
if (!check.ok) return { kind: "rejected", reason: check.error };
await writeOrders(tx, check.ids);
return { kind: "reordered" };
});
}

View file

@ -0,0 +1,332 @@
/**
* GET / POST / PATCH `.../workstreams/[projectWorkstreamId]/stages` (#411).
*
* The database is mocked at the module boundary (`./queries` and the authz
* repository); no connection is opened. The authorization *decisions* are the
* real ones from `@/lib/projects/authz` only the facts they read are faked.
*/
import { describe, expect, it, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const {
mockGetServerSession,
mockLoadAuthzUser,
mockLoadProjectAuthzFacts,
mockFindLadder,
mockAddStage,
mockReorderStages,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockFindLadder: vi.fn(),
mockAddStage: vi.fn(),
mockReorderStages: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("@/app/repositories/projects/authzRepository", () => ({
loadAuthzUser: mockLoadAuthzUser,
loadProjectAuthzFacts: mockLoadProjectAuthzFacts,
}));
vi.mock("./queries", () => ({
findLadder: mockFindLadder,
addStage: mockAddStage,
reorderStages: mockReorderStages,
}));
import { GET, PATCH, POST } from "./route";
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
const OTHER_ORG = "33333333-3333-3333-3333-333333333333";
const NO_DEPENDENTS = {
workOrders: 0,
evidenceRequirements: 0,
isCurrentStage: false,
};
const LADDER = {
projectWorkstreamId: "9",
workstreamId: "1",
workstreamName: "Windows",
isDefault: true,
stages: [
{
id: "100",
name: "Ordered",
order: 0,
startDate: null,
dueDate: null,
dependents: NO_DEPENDENTS,
},
{
id: "101",
name: "Closed",
order: 1,
startDate: "2026-09-03",
dueDate: null,
dependents: NO_DEPENDENTS,
},
],
};
function signedInAs(organisationIds: string[]) {
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
mockLoadAuthzUser.mockResolvedValue({
id: 7n,
email: "someone@landlord.example",
organisationIds,
});
}
function projectExists() {
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: false,
contractorOrganisationIds: [CONTRACTOR_ORG],
});
}
const params = (projectId = "5", projectWorkstreamId = "9") => ({
params: Promise.resolve({ projectId, projectWorkstreamId }),
});
const url = (projectId = "5", projectWorkstreamId = "9") =>
`http://localhost/api/projects/${projectId}/workstreams/${projectWorkstreamId}/stages`;
function jsonRequest(method: string, body: unknown) {
return new NextRequest(url(), {
method,
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
mockFindLadder.mockResolvedValue(LADDER);
mockAddStage.mockResolvedValue({ kind: "added", stageId: "102" });
mockReorderStages.mockResolvedValue({ kind: "reordered" });
});
describe("GET", () => {
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorised" });
});
it("404s a project the caller may not see, rather than 403", async () => {
signedInAs([OTHER_ORG]);
projectExists();
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(404);
expect(mockFindLadder).not.toHaveBeenCalled();
});
it("400s a non-numeric workstream id", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(new NextRequest(url("5", "abc")), params("5", "abc"));
expect(res.status).toBe(400);
});
it("404s a workstream that is not selected on this project", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockFindLadder.mockResolvedValue(null);
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(404);
});
it("returns the ladder and whether the caller may edit it", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
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);
});
it("lets a contractor read the ladder but not manage it", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(200);
expect((await res.json()).canManage).toBe(false);
});
});
describe("POST", () => {
beforeEach(() => {
signedInAs([CLIENT_ORG]);
projectExists();
});
it("403s a caller who may view but not manage", async () => {
signedInAs([CONTRACTOR_ORG]);
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(403);
expect(mockAddStage).not.toHaveBeenCalled();
});
it("adds a stage", async () => {
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ stageId: "102" });
expect(mockAddStage).toHaveBeenCalledWith(9n, {
name: "Snagging",
startDate: null,
dueDate: null,
});
});
it("accepts the optional dates as YYYY-MM-DD", async () => {
const res = await POST(
jsonRequest("POST", {
name: "Snagging",
startDate: "2026-09-03",
dueDate: "2026-12-01",
}),
params(),
);
expect(res.status).toBe(201);
expect(mockAddStage).toHaveBeenCalledWith(9n, {
name: "Snagging",
startDate: "2026-09-03",
dueDate: "2026-12-01",
});
});
it("rejects a date that is not YYYY-MM-DD", async () => {
// dd/mm/yyyy is what the user types; DateInput converts before it is sent.
const res = await POST(
jsonRequest("POST", { name: "Snagging", startDate: "03/09/2026" }),
params(),
);
expect(res.status).toBe(400);
expect(mockAddStage).not.toHaveBeenCalled();
});
it("treats an empty date string as no date", async () => {
const res = await POST(
jsonRequest("POST", { name: "Snagging", startDate: "", dueDate: "" }),
params(),
);
expect(res.status).toBe(201);
expect(mockAddStage).toHaveBeenCalledWith(9n, {
name: "Snagging",
startDate: null,
dueDate: null,
});
});
it("rejects a due date before the start date", async () => {
const res = await POST(
jsonRequest("POST", {
name: "Snagging",
startDate: "2026-09-03",
dueDate: "2026-03-09",
}),
params(),
);
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("before the start date");
expect(mockAddStage).not.toHaveBeenCalled();
});
it("400s a body with no name", async () => {
const res = await POST(jsonRequest("POST", {}), params());
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid body" });
});
it("passes a rejected name back as a 400 with its reason", async () => {
mockAddStage.mockResolvedValue({
kind: "rejected",
reason: 'This workstream already has a "Ordered" stage.',
});
const res = await POST(jsonRequest("POST", { name: "Ordered" }), params());
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("already has");
});
it("404s a workstream of another project without writing", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(404);
expect(mockAddStage).not.toHaveBeenCalled();
});
it("500s when the write fails", async () => {
mockAddStage.mockRejectedValue(new Error("boom"));
vi.spyOn(console, "error").mockImplementation(() => {});
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "Couldn't add the stage" });
});
});
describe("PATCH (reorder)", () => {
beforeEach(() => {
signedInAs([CLIENT_ORG]);
projectExists();
});
it("403s a caller who may not manage", async () => {
signedInAs([CONTRACTOR_ORG]);
const res = await PATCH(
jsonRequest("PATCH", { stageIds: ["101", "100"] }),
params(),
);
expect(res.status).toBe(403);
expect(mockReorderStages).not.toHaveBeenCalled();
});
it("reorders the ladder", async () => {
const res = await PATCH(
jsonRequest("PATCH", { stageIds: ["101", "100"] }),
params(),
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ reordered: true });
expect(mockReorderStages).toHaveBeenCalledWith(9n, ["101", "100"]);
});
it("409s a stale list rather than applying it", async () => {
mockReorderStages.mockResolvedValue({
kind: "rejected",
reason: "The stage list has changed. Reload and try again.",
});
const res = await PATCH(
jsonRequest("PATCH", { stageIds: ["101"] }),
params(),
);
expect(res.status).toBe(409);
expect((await res.json()).error).toContain("Reload");
});
it("400s a body that is not a list of ids", async () => {
const res = await PATCH(
jsonRequest("PATCH", { stageIds: [100, 101] }),
params(),
);
expect(res.status).toBe(400);
expect(mockReorderStages).not.toHaveBeenCalled();
});
it("404s a workstream of another project without writing", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await PATCH(
jsonRequest("PATCH", { stageIds: ["101", "100"] }),
params(),
);
expect(res.status).toBe(404);
expect(mockReorderStages).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,185 @@
/**
* `/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages` (#411).
*
* GET one workstream's ladder, stages in `order`, with the counts that
* explain why a stage may not be deletable.
* POST add a stage to the end of the ladder.
* PATCH reorder the whole ladder (`{ stageIds }`, every id exactly once).
*
* 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.
*
* Authorization is `../../authorize` unchanged (#410): view to read, manage to
* write, and a project the caller cannot see reads as 404 rather than 403.
*/
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { authorizeWorkstreamRequest } from "../../authorize";
import { checkStageDates } from "./ladder";
import { addStage, findLadder, reorderStages } from "./queries";
type Params = {
params: Promise<{ projectId: string; projectWorkstreamId: string }>;
};
/** `YYYY-MM-DD` — see ADR-0019: the display format stops at the input's edge. */
const isoDate = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Dates must be YYYY-MM-DD");
/**
* An optional date field. The client sends `null` to clear one and omits the
* key to leave it alone, so `""` is normalised to `null` rather than stored.
*/
const optionalIsoDate = isoDate
.or(z.literal(""))
.nullish()
.transform((value) => (value ? value : null));
const addSchema = z.object({
name: z.string(),
startDate: optionalIsoDate,
dueDate: optionalIsoDate,
});
const reorderSchema = z.object({
/** Every stage id of this ladder, in the order they should end up in. */
stageIds: z.array(z.string().regex(/^\d+$/, "Stage ids must be numeric")),
});
/** Parses `[projectWorkstreamId]`; 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 auth = await authorizeWorkstreamRequest(projectId, "view");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseWorkstreamId(projectWorkstreamId);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
return NextResponse.json({ ladder, canManage: auth.canManage });
}
/** POST — append a stage. */
export async function POST(req: NextRequest, props: Params) {
const { projectId, projectWorkstreamId } = 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);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
let body: z.infer<typeof addSchema>;
try {
body = addSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const dates = checkStageDates(body.startDate, body.dueDate);
if (!dates.ok) {
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))) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
try {
const result = await addStage(workstreamId, {
name: body.name,
startDate: body.startDate,
dueDate: body.dueDate,
});
if (result.kind === "rejected") {
return NextResponse.json({ error: result.reason }, { status: 400 });
}
return NextResponse.json({ stageId: result.stageId }, { status: 201 });
} catch (err) {
console.error("POST .../workstreams/[id]/stages failed:", err);
return NextResponse.json(
{ error: "Couldn't add the stage" },
{ status: 500 },
);
}
}
/**
* PATCH reorder the ladder.
*
* The whole sequence is sent, not a "move up" instruction, so the server never
* has to guess what the client was looking at: a list that no longer matches
* 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 auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseWorkstreamId(projectWorkstreamId);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
let body: z.infer<typeof reorderSchema>;
try {
body = reorderSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
if (!(await findLadder(auth.projectId, workstreamId))) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
try {
const result = await reorderStages(workstreamId, 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);
return NextResponse.json(
{ error: "Couldn't reorder the stages" },
{ status: 500 },
);
}
}