mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
bf3f8aeb20
commit
a57fc5d0d2
7 changed files with 1993 additions and 0 deletions
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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" };
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue