feat(ara-projects): workstream selection route handlers (#410)

Adds POST/DELETE/GET under /api/projects/[projectId]/workstreams so the
setup screen can persist its selection as project_workstream rows.

The deselect cascade rules live in a pure `cascade.ts` — work orders block
a deselect outright; configured stages, evidence requirements and
contractor assignments warn and require `?confirm=true`, then cascade.
Keeping them pure lets the browser import the same wording and the rules
unit-test without a connection.

Authorization is wired, never re-implemented: facts from the #408 authz
repository, decisions from @/lib/projects/authz. A project the caller
cannot see reports 404, not 403, matching the route-shell guard.

Dependent counts are one round trip with count(distinct) over four left
joins; a Work order is reached by both its stage and its contractor FK, so
either path blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-23 10:07:05 +00:00
parent fa4f3f3ab0
commit a8589759c5
8 changed files with 1115 additions and 0 deletions

View file

@ -0,0 +1,184 @@
/**
* DELETE `/api/projects/[projectId]/workstreams/[workstreamId]` (issue #410)
* the deselect-with-dependents path.
*
* The database is mocked at the module boundary; no connection is opened. The
* cascade rules under test are the real ones from `../cascade`.
*/
import { describe, expect, it, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
import { NO_DEPENDENTS, type WorkstreamDependents } from "../cascade";
const {
mockGetServerSession,
mockLoadAuthzUser,
mockLoadProjectAuthzFacts,
mockFindSelection,
mockDeselectWorkstream,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockFindSelection: vi.fn(),
mockDeselectWorkstream: 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", () => ({
findSelection: mockFindSelection,
deselectWorkstream: mockDeselectWorkstream,
}));
import { DELETE } from "./route";
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
function signedInAs(organisationIds: string[]) {
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
mockLoadAuthzUser.mockResolvedValue({
id: 7n,
email: "someone@landlord.example",
organisationIds,
});
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: false,
contractorOrganisationIds: [CONTRACTOR_ORG],
});
}
function selectionWith(dependents: Partial<WorkstreamDependents>) {
mockFindSelection.mockResolvedValue({
id: 9n,
dependents: { ...NO_DEPENDENTS, ...dependents },
});
}
function request({
confirm = false,
projectId = "5",
workstreamId = "1",
}: { confirm?: boolean; projectId?: string; workstreamId?: string } = {}) {
const url = `http://localhost/api/projects/${projectId}/workstreams/${workstreamId}${
confirm ? "?confirm=true" : ""
}`;
return {
req: new NextRequest(url, { method: "DELETE" }),
props: { params: Promise.resolve({ projectId, workstreamId }) },
};
}
beforeEach(() => {
vi.clearAllMocks();
mockDeselectWorkstream.mockResolvedValue(undefined);
selectionWith({});
});
describe("DELETE", () => {
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, props } = request();
const res = await DELETE(req, props);
expect(res.status).toBe(401);
});
it("403s for a contractor", async () => {
signedInAs([CONTRACTOR_ORG]);
const { req, props } = request();
const res = await DELETE(req, props);
expect(res.status).toBe(403);
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
});
it("400s on a non-numeric workstream id", async () => {
signedInAs([CLIENT_ORG]);
const { req, props } = request({ workstreamId: "windows" });
const res = await DELETE(req, props);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid workstream" });
});
it("404s when the workstream is not selected on the project", async () => {
signedInAs([CLIENT_ORG]);
mockFindSelection.mockResolvedValue(null);
const { req, props } = request();
const res = await DELETE(req, props);
expect(res.status).toBe(404);
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
});
it("deselects a workstream nothing hangs off", async () => {
signedInAs([CLIENT_ORG]);
const { req, props } = request();
const res = await DELETE(req, props);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ workstreamId: "1", selected: false });
expect(mockDeselectWorkstream).toHaveBeenCalledWith(9n);
});
it("409s asking for confirmation when configuration exists", async () => {
signedInAs([CLIENT_ORG]);
selectionWith({ stages: 5, contractors: 1 });
const { req, props } = request();
const res = await DELETE(req, props);
expect(res.status).toBe(409);
const body = await res.json();
expect(body).toMatchObject({
requiresConfirmation: true,
dependents: { stages: 5, contractors: 1 },
});
expect(body.error).toContain("5 stages and 1 contractor assignment");
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
});
it("deselects once the caller confirms", async () => {
signedInAs([CLIENT_ORG]);
selectionWith({ stages: 5, evidenceRequirements: 2, contractors: 1 });
const { req, props } = request({ confirm: true });
const res = await DELETE(req, props);
expect(res.status).toBe(200);
expect(mockDeselectWorkstream).toHaveBeenCalledWith(9n);
});
it("409s blocked when work orders exist", async () => {
signedInAs([CLIENT_ORG]);
selectionWith({ stages: 5, workOrders: 3 });
const { req, props } = request();
const res = await DELETE(req, props);
expect(res.status).toBe(409);
const body = await res.json();
expect(body).toMatchObject({ blocked: true, dependents: { workOrders: 3 } });
expect(body.requiresConfirmation).toBeUndefined();
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
});
it("stays blocked when the caller confirms anyway", async () => {
signedInAs([CLIENT_ORG]);
selectionWith({ workOrders: 1 });
const { req, props } = request({ confirm: true });
const res = await DELETE(req, props);
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ blocked: true });
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
});
it("500s without leaking the driver error", async () => {
signedInAs([CLIENT_ORG]);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mockDeselectWorkstream.mockRejectedValue(new Error("deadlock detected"));
const { req, props } = request();
const res = await DELETE(req, props);
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
error: "Couldn't remove the workstream",
});
consoleError.mockRestore();
});
});

View file

@ -0,0 +1,83 @@
/**
* `DELETE /api/projects/[projectId]/workstreams/[workstreamId]` (issue #410).
*
* Deselect a workstream. Keyed on `workstream.id` rather than the
* `project_workstream.id` so the card grid can call it with the id it already
* renders, and so a repeat call is a clean 404 rather than a dangling handle.
*
* The cascade rules (`../cascade`) decide the outcome:
* - work orders exist 409, `blocked: true`, never overridable
* - configuration exists 409, `requiresConfirmation: true`, until
* the caller repeats the request with
* `?confirm=true`
* - otherwise deleted
*
* The 409 bodies keep the `{ error: string }` shape and add the flags and
* counts the UI needs to raise the right dialog.
*/
import { NextRequest, NextResponse } from "next/server";
import { authorizeWorkstreamRequest } from "../authorize";
import { decideDeselect } from "../cascade";
import { deselectWorkstream, findSelection } from "../queries";
type Params = { params: Promise<{ projectId: string; workstreamId: string }> };
export async function DELETE(req: NextRequest, props: Params) {
const { projectId, workstreamId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
if (!/^\d+$/.test(workstreamId)) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
const selection = await findSelection(auth.projectId, BigInt(workstreamId));
if (!selection) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
const confirmed = req.nextUrl.searchParams.get("confirm") === "true";
const decision = decideDeselect(selection.dependents, confirmed);
if (decision.kind === "blocked") {
return NextResponse.json(
{
error: decision.reason,
blocked: true,
dependents: selection.dependents,
},
{ status: 409 },
);
}
if (decision.kind === "needs-confirmation") {
return NextResponse.json(
{
error: decision.reason,
requiresConfirmation: true,
dependents: selection.dependents,
},
{ status: 409 },
);
}
try {
await deselectWorkstream(selection.id);
} catch (err) {
console.error(
"DELETE /api/projects/[projectId]/workstreams/[workstreamId] failed:",
err,
);
return NextResponse.json(
{ error: "Couldn't remove the workstream" },
{ status: 500 },
);
}
return NextResponse.json({ workstreamId, selected: false });
}

View file

@ -0,0 +1,71 @@
/**
* Request authorization for the workstream-selection endpoints (issue #410).
*
* Thin wiring only: the session comes from NextAuth, the facts from the
* projects authz repository, and every *decision* from `@/lib/projects/authz`
* (#408). Nothing here re-implements a permission rule.
*
* A project the caller cannot see is reported as 404, never 403, so project
* existence is not leaked outside its organisation matching the guard in
* `src/app/projects/authz.ts`.
*/
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import {
loadAuthzUser,
loadProjectAuthzFacts,
} from "@/app/repositories/projects/authzRepository";
import { canManageProject, canViewProject } from "@/lib/projects/authz";
export type AuthorizeFailure = { error: string; status: 400 | 401 | 403 | 404 };
export type AuthorizeSuccess = {
projectId: bigint;
/** Whether the caller may edit the selection, not merely read it. */
canManage: boolean;
};
export type AuthorizeResult =
| ({ ok: true } & AuthorizeSuccess)
| ({ ok: false } & AuthorizeFailure);
/**
* Resolve the caller's standing on `projectIdParam`.
*
* `capability` is what the request needs: `"view"` for a read, `"manage"` for
* a write. The returned `canManage` lets a read tell the client whether to
* render the grid editable.
*/
export async function authorizeWorkstreamRequest(
projectIdParam: string,
capability: "view" | "manage",
): Promise<AuthorizeResult> {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return { ok: false, error: "Unauthorised", status: 401 };
}
if (!/^\d+$/.test(projectIdParam)) {
return { ok: false, error: "Invalid project", status: 400 };
}
const projectId = BigInt(projectIdParam);
const [user, project] = await Promise.all([
loadAuthzUser(BigInt(session.user.dbId)),
loadProjectAuthzFacts(projectId),
]);
if (!user) return { ok: false, error: "Unauthorised", status: 401 };
if (!project) return { ok: false, error: "Project not found", status: 404 };
if (!canViewProject(user, project)) {
return { ok: false, error: "Project not found", status: 404 };
}
const canManage = canManageProject(user, project);
if (capability === "manage" && !canManage) {
return { ok: false, error: "Forbidden", status: 403 };
}
return { ok: true, projectId, canManage };
}

View file

@ -0,0 +1,124 @@
/**
* The deselect cascade rules (issue #410). Pure logic no mocks needed.
*/
import { describe, expect, it } from "vitest";
import {
NO_DEPENDENTS,
cascadingCount,
decideDeselect,
describeDependents,
type WorkstreamDependents,
} from "./cascade";
function dependents(
overrides: Partial<WorkstreamDependents> = {},
): WorkstreamDependents {
return { ...NO_DEPENDENTS, ...overrides };
}
describe("decideDeselect", () => {
it("allows deselecting a workstream nothing hangs off", () => {
expect(decideDeselect(dependents(), false)).toEqual({ kind: "allowed" });
});
it("asks for confirmation when stages are configured", () => {
const decision = decideDeselect(dependents({ stages: 5 }), false);
expect(decision.kind).toBe("needs-confirmation");
expect(decision).toHaveProperty("reason", expect.stringContaining("5 stages"));
});
it("asks for confirmation when evidence requirements exist", () => {
expect(
decideDeselect(dependents({ evidenceRequirements: 1 }), false).kind,
).toBe("needs-confirmation");
});
it("asks for confirmation when a contractor is assigned", () => {
expect(decideDeselect(dependents({ contractors: 1 }), false).kind).toBe(
"needs-confirmation",
);
});
it("proceeds once the user has confirmed", () => {
expect(
decideDeselect(
dependents({ stages: 5, evidenceRequirements: 2, contractors: 1 }),
true,
),
).toEqual({ kind: "allowed" });
});
it("blocks when work orders exist", () => {
const decision = decideDeselect(dependents({ workOrders: 3 }), false);
expect(decision.kind).toBe("blocked");
expect(decision).toHaveProperty(
"reason",
expect.stringContaining("3 work orders"),
);
});
it("keeps blocking work orders even when the user confirms", () => {
// Confirmation acknowledges a warning; it is not an override.
expect(decideDeselect(dependents({ workOrders: 1 }), true).kind).toBe(
"blocked",
);
});
it("reports the work-order block ahead of the configuration warning", () => {
const decision = decideDeselect(
dependents({ stages: 4, workOrders: 2 }),
false,
);
expect(decision.kind).toBe("blocked");
});
it("singularises a lone work order", () => {
expect(decideDeselect(dependents({ workOrders: 1 }), false)).toHaveProperty(
"reason",
expect.stringContaining("1 work order "),
);
});
});
describe("describeDependents", () => {
it("is empty when nothing is configured", () => {
expect(describeDependents(dependents())).toBe("");
});
it("singularises a single dependent", () => {
expect(describeDependents(dependents({ stages: 1 }))).toBe("1 stage");
});
it("joins the last pair with 'and'", () => {
expect(
describeDependents(
dependents({ stages: 5, evidenceRequirements: 2, contractors: 1 }),
),
).toBe("5 stages, 2 evidence requirements and 1 contractor assignment");
});
it("omits the categories that are empty", () => {
expect(describeDependents(dependents({ stages: 3, contractors: 2 }))).toBe(
"3 stages and 2 contractor assignments",
);
});
it("ignores work orders, which are never deleted by a cascade", () => {
expect(describeDependents(dependents({ workOrders: 9 }))).toBe("");
});
});
describe("cascadingCount", () => {
it("counts only the rows a confirmed deselect would delete", () => {
expect(
cascadingCount(
dependents({
stages: 5,
evidenceRequirements: 2,
contractors: 1,
workOrders: 7,
}),
),
).toBe(8);
});
});

View file

@ -0,0 +1,105 @@
/**
* Deselect cascade rules for a Project Workstream (issue #410).
*
* Deliberately **pure** it imports no database client and no React so the
* route handler, the unit tests and the browser bundle can all share one copy
* of the rules. The counts themselves are loaded in `./queries`.
*
* Two rules, in priority order:
* 1. **Work orders block.** Work has been issued against the workstream; the
* selection can no longer be withdrawn from the setup screen.
* 2. **Configuration warns.** Stages, evidence requirements and contractor
* assignments are the workstream's own configuration deselecting throws
* them away, so the user must confirm first.
*
* Everything else deselects silently.
*/
/** What already hangs off one Project Workstream. */
export interface WorkstreamDependents {
stages: number;
evidenceRequirements: number;
contractors: number;
workOrders: number;
}
/** An unselected workstream, or a selected one nothing hangs off yet. */
export const NO_DEPENDENTS: WorkstreamDependents = {
stages: 0,
evidenceRequirements: 0,
contractors: 0,
workOrders: 0,
};
export type DeselectDecision =
| { kind: "blocked"; reason: string }
| { kind: "needs-confirmation"; reason: string }
| { kind: "allowed" };
/** The dependents that are destroyed by a confirmed deselect. */
const CASCADING: Array<{
key: keyof WorkstreamDependents;
one: string;
many: string;
}> = [
{ key: "stages", one: "stage", many: "stages" },
{
key: "evidenceRequirements",
one: "evidence requirement",
many: "evidence requirements",
},
{
key: "contractors",
one: "contractor assignment",
many: "contractor assignments",
},
];
/** How many rows a confirmed deselect would delete. */
export function cascadingCount(dependents: WorkstreamDependents): number {
return CASCADING.reduce((total, { key }) => total + dependents[key], 0);
}
/** `"5 stages, 1 evidence requirement and 2 contractor assignments"`. */
export function describeDependents(dependents: WorkstreamDependents): string {
const parts = CASCADING.filter(({ key }) => dependents[key] > 0).map(
({ key, one, many }) =>
`${dependents[key]} ${dependents[key] === 1 ? one : many}`,
);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0];
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
}
/**
* Whether a deselect may proceed.
*
* `confirmed` is the user having acknowledged the warning (the `?confirm=true`
* query parameter on DELETE). It never unblocks a work-order block that is a
* hard rule, not a warning.
*/
export function decideDeselect(
dependents: WorkstreamDependents,
confirmed: boolean,
): DeselectDecision {
if (dependents.workOrders > 0) {
const plural = dependents.workOrders === 1 ? "work order" : "work orders";
return {
kind: "blocked",
reason:
`This workstream has ${dependents.workOrders} ${plural} against it. ` +
`Work orders must be removed before the workstream can be taken off the project.`,
};
}
if (!confirmed && cascadingCount(dependents) > 0) {
return {
kind: "needs-confirmation",
reason:
`Removing this workstream also deletes ${describeDependents(dependents)}. ` +
`This can't be undone.`,
};
}
return { kind: "allowed" };
}

View file

@ -0,0 +1,248 @@
/**
* Persistence for the workstream-selection setup screen (issue #410).
*
* The route handlers and the server-rendered page both read through here, so
* the two always agree on shape the page renders the first paint and the
* handlers serve every refetch afterwards.
*
* The workstream catalogue is reference data seeded by #407 (migration 0274);
* it is always read from the `workstream` table, never hard-coded.
*/
import { db } from "@/app/db/db";
import { and, eq, or, sql } from "drizzle-orm";
import {
projectWorkstream,
projectWorkstreamContractor,
projectWorkstreamEvidenceRequirement,
projectWorkstreamStage,
workOrder,
workstream,
} from "@/app/db/schema/projects/projects";
import { NO_DEPENDENTS, type WorkstreamDependents } from "./cascade";
/** One card in the grid: a catalogue row plus this project's standing on it. */
export interface WorkstreamOption {
/** `workstream.id`, serialised — the id the POST/DELETE endpoints take. */
id: string;
name: string;
description: string;
selected: boolean;
/** `project_workstream.id` when selected, else null. */
projectWorkstreamId: string | null;
/** All zero when unselected. Drives the badges and the deselect warnings. */
dependents: WorkstreamDependents;
}
interface DependentRow extends WorkstreamDependents {
id: bigint;
workstreamId: bigint;
}
/**
* Count what hangs off each Project Workstream matching `where`.
*
* One round trip: the four child tables are left-joined and counted with
* `count(distinct)`, so the join fan-out between them does not inflate the
* numbers. A Work order reaches its Project Workstream by two independent FKs
* (its stage and its contractor assignment) both are followed, so a row
* hanging off either path still blocks the deselect.
*/
async function loadDependentRows(
where: ReturnType<typeof and>,
): Promise<DependentRow[]> {
return db
.select({
id: projectWorkstream.id,
workstreamId: projectWorkstream.workstreamId,
stages: sql<number>`count(distinct ${projectWorkstreamStage.id})::int`,
evidenceRequirements: sql<number>`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`,
contractors: sql<number>`count(distinct ${projectWorkstreamContractor.id})::int`,
workOrders: sql<number>`count(distinct ${workOrder.id})::int`,
})
.from(projectWorkstream)
.leftJoin(
projectWorkstreamStage,
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id),
)
.leftJoin(
projectWorkstreamEvidenceRequirement,
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
projectWorkstream.id,
),
)
.leftJoin(
projectWorkstreamContractor,
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
)
.leftJoin(
workOrder,
or(
eq(workOrder.projectWorkstreamStageId, projectWorkstreamStage.id),
eq(
workOrder.projectWorkstreamContractorId,
projectWorkstreamContractor.id,
),
),
)
.where(where)
.groupBy(projectWorkstream.id, projectWorkstream.workstreamId);
}
/**
* The whole card grid for one project: every seeded workstream, marked with
* whether this project has selected it and what is configured underneath.
*/
export async function listWorkstreamOptions(
projectId: bigint,
): Promise<WorkstreamOption[]> {
const [catalogue, selections] = await Promise.all([
db
.select({
id: workstream.id,
name: workstream.name,
description: workstream.description,
})
.from(workstream)
.orderBy(workstream.id),
loadDependentRows(eq(projectWorkstream.projectId, projectId)),
]);
const byWorkstreamId = new Map(
selections.map((row) => [row.workstreamId.toString(), row]),
);
return catalogue.map((row) => {
const selection = byWorkstreamId.get(row.id.toString());
return {
id: row.id.toString(),
name: row.name,
description: row.description,
selected: Boolean(selection),
projectWorkstreamId: selection ? selection.id.toString() : null,
dependents: selection
? {
stages: selection.stages,
evidenceRequirements: selection.evidenceRequirements,
contractors: selection.contractors,
workOrders: selection.workOrders,
}
: NO_DEPENDENTS,
};
});
}
/** True when `workstreamId` is a real catalogue row. */
export async function workstreamExists(workstreamId: bigint): Promise<boolean> {
const [found] = await db
.select({ id: workstream.id })
.from(workstream)
.where(eq(workstream.id, workstreamId))
.limit(1);
return Boolean(found);
}
/**
* The `project_workstream` row for one (project, workstream) pair, with its
* dependent counts. Null when the workstream is not selected on the project.
*/
export async function findSelection(
projectId: bigint,
workstreamId: bigint,
): Promise<{ id: bigint; dependents: WorkstreamDependents } | null> {
const [row] = await loadDependentRows(
and(
eq(projectWorkstream.projectId, projectId),
eq(projectWorkstream.workstreamId, workstreamId),
),
);
if (!row) return null;
return {
id: row.id,
dependents: {
stages: row.stages,
evidenceRequirements: row.evidenceRequirements,
contractors: row.contractors,
workOrders: row.workOrders,
},
};
}
/**
* Select a workstream for a project, returning the `project_workstream` id.
*
* Idempotent: re-selecting an already-selected workstream returns the existing
* row rather than a duplicate. `project_workstream` carries no unique index on
* (project_id, workstream_id), so the check-then-insert is done inside a
* transaction the narrowest guard available without a migration.
*/
export async function selectWorkstream(
projectId: bigint,
workstreamId: bigint,
): Promise<{ id: bigint; created: boolean }> {
return db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: projectWorkstream.id })
.from(projectWorkstream)
.where(
and(
eq(projectWorkstream.projectId, projectId),
eq(projectWorkstream.workstreamId, workstreamId),
),
)
.limit(1);
if (existing) return { id: existing.id, created: false };
const [inserted] = await tx
.insert(projectWorkstream)
.values({ projectId, workstreamId })
.returning({ id: projectWorkstream.id });
return { id: inserted.id, created: true };
});
}
/**
* Deselect a workstream, deleting the configuration that hangs off it.
*
* Callers must have run `decideDeselect` first: this deletes stages, evidence
* requirements and contractor assignments unconditionally, and would fail on
* the work-order FK if any work had been issued.
*
* `current_stage_id` is nulled before the stages go, because the Project
* Workstream points back at one of its own stages.
*/
export async function deselectWorkstream(
projectWorkstreamId: bigint,
): Promise<void> {
await db.transaction(async (tx) => {
await tx
.update(projectWorkstream)
.set({ currentStageId: null })
.where(eq(projectWorkstream.id, projectWorkstreamId));
await tx
.delete(projectWorkstreamEvidenceRequirement)
.where(
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
projectWorkstreamId,
),
);
await tx
.delete(projectWorkstreamContractor)
.where(
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstreamId),
);
await tx
.delete(projectWorkstreamStage)
.where(eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId));
await tx
.delete(projectWorkstream)
.where(eq(projectWorkstream.id, projectWorkstreamId));
});
}

View file

@ -0,0 +1,228 @@
/**
* GET / POST `/api/projects/[projectId]/workstreams` (issue #410).
*
* 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,
mockListWorkstreamOptions,
mockSelectWorkstream,
mockWorkstreamExists,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockListWorkstreamOptions: vi.fn(),
mockSelectWorkstream: vi.fn(),
mockWorkstreamExists: 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", () => ({
listWorkstreamOptions: mockListWorkstreamOptions,
selectWorkstream: mockSelectWorkstream,
workstreamExists: mockWorkstreamExists,
}));
import { GET, 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 CATALOGUE = [
{
id: "1",
name: "Windows",
description: "Replacement and repair of windows.",
selected: true,
projectWorkstreamId: "9",
dependents: {
stages: 5,
evidenceRequirements: 0,
contractors: 0,
workOrders: 0,
},
},
{
id: "2",
name: "Doors",
description: "Replacement and repair of doors.",
selected: false,
projectWorkstreamId: null,
dependents: {
stages: 0,
evidenceRequirements: 0,
contractors: 0,
workOrders: 0,
},
},
];
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") => ({ params: Promise.resolve({ projectId }) });
function getRequest(projectId = "5") {
return new NextRequest(
`http://localhost/api/projects/${projectId}/workstreams`,
);
}
function postRequest(body: unknown, projectId = "5") {
return new NextRequest(
`http://localhost/api/projects/${projectId}/workstreams`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
}
beforeEach(() => {
vi.clearAllMocks();
mockListWorkstreamOptions.mockResolvedValue(CATALOGUE);
mockWorkstreamExists.mockResolvedValue(true);
mockSelectWorkstream.mockResolvedValue({ id: 42n, created: true });
});
describe("GET", () => {
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await GET(getRequest(), params());
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorised" });
});
it("400s on a non-numeric project id", async () => {
signedInAs([CLIENT_ORG]);
const res = await GET(getRequest("abc"), params("abc"));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid project" });
});
it("404s when the project does not exist", async () => {
signedInAs([CLIENT_ORG]);
mockLoadProjectAuthzFacts.mockResolvedValue(null);
const res = await GET(getRequest(), params());
expect(res.status).toBe(404);
});
it("404s rather than 403s for a user outside the project", async () => {
signedInAs([OTHER_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(404);
expect(mockListWorkstreamOptions).not.toHaveBeenCalled();
});
it("returns the catalogue with the project's selections", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
workstreams: CATALOGUE,
canManage: true,
});
expect(mockListWorkstreamOptions).toHaveBeenCalledWith(5n);
});
it("lets a contractor read but marks the grid unmanageable", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ canManage: false });
});
});
describe("POST", () => {
it("403s for a contractor, who may see setup but not edit it", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await POST(postRequest({ workstreamId: "2" }), params());
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: "Forbidden" });
expect(mockSelectWorkstream).not.toHaveBeenCalled();
});
it("400s on a body without a numeric workstreamId", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await POST(postRequest({ workstreamId: "windows" }), params());
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid body" });
});
it("404s when the workstream is not in the catalogue", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockWorkstreamExists.mockResolvedValue(false);
const res = await POST(postRequest({ workstreamId: "99" }), params());
expect(res.status).toBe(404);
expect(mockSelectWorkstream).not.toHaveBeenCalled();
});
it("201s on a new selection", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await POST(postRequest({ workstreamId: "2" }), params());
expect(res.status).toBe(201);
expect(await res.json()).toEqual({
projectWorkstreamId: "42",
workstreamId: "2",
selected: true,
});
expect(mockSelectWorkstream).toHaveBeenCalledWith(5n, 2n);
});
it("200s when the workstream was already selected", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockSelectWorkstream.mockResolvedValue({ id: 9n, created: false });
const res = await POST(postRequest({ workstreamId: "1" }), params());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ projectWorkstreamId: "9" });
});
it("500s without leaking the driver error", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mockSelectWorkstream.mockRejectedValue(new Error("connection reset"));
const res = await POST(postRequest({ workstreamId: "2" }), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "Couldn't add the workstream" });
consoleError.mockRestore();
});
});

View file

@ -0,0 +1,72 @@
/**
* `/api/projects/[projectId]/workstreams` (issue #410).
*
* GET the seeded workstream catalogue, marked with this project's
* selection and what is configured under each selected one.
* POST select a workstream (insert a `project_workstream` row).
*
* Deselect lives on `[workstreamId]/route.ts` because its cascade rules need
* to name a single workstream.
*/
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { authorizeWorkstreamRequest } from "./authorize";
import { listWorkstreamOptions, selectWorkstream, workstreamExists } from "./queries";
type Params = { params: Promise<{ projectId: string }> };
const selectSchema = z.object({
/** `workstream.id` as a string — ids are bigint and do not survive JSON. */
workstreamId: z.string().regex(/^\d+$/, "workstreamId must be a numeric id"),
});
/** GET — the card grid's data, re-read from the DB on every request. */
export async function GET(_req: NextRequest, props: Params) {
const { projectId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "view");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreams = await listWorkstreamOptions(auth.projectId);
return NextResponse.json({ workstreams, canManage: auth.canManage });
}
/** POST — select a workstream for the project. Idempotent. */
export async function POST(req: NextRequest, props: Params) {
const { projectId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
let body: z.infer<typeof selectSchema>;
try {
body = selectSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const workstreamId = BigInt(body.workstreamId);
if (!(await workstreamExists(workstreamId))) {
return NextResponse.json({ error: "Workstream not found" }, { status: 404 });
}
try {
const { id, created } = await selectWorkstream(auth.projectId, workstreamId);
return NextResponse.json(
{
projectWorkstreamId: id.toString(),
workstreamId: body.workstreamId,
selected: true,
},
{ status: created ? 201 : 200 },
);
} catch (err) {
console.error("POST /api/projects/[projectId]/workstreams failed:", err);
return NextResponse.json(
{ error: "Couldn't add the workstream" },
{ status: 500 },
);
}
}