From a8589759c5135cd2b7bc09202b8c13267eab1859 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 10:07:05 +0000 Subject: [PATCH 1/3] feat(ara-projects): workstream selection route handlers (#410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../workstreams/[workstreamId]/route.test.ts | 184 +++++++++++++ .../workstreams/[workstreamId]/route.ts | 83 ++++++ .../[projectId]/workstreams/authorize.ts | 71 +++++ .../[projectId]/workstreams/cascade.test.ts | 124 +++++++++ .../[projectId]/workstreams/cascade.ts | 105 ++++++++ .../[projectId]/workstreams/queries.ts | 248 ++++++++++++++++++ .../[projectId]/workstreams/route.test.ts | 228 ++++++++++++++++ .../projects/[projectId]/workstreams/route.ts | 72 +++++ 8 files changed, 1115 insertions(+) create mode 100644 src/app/api/projects/[projectId]/workstreams/[workstreamId]/route.test.ts create mode 100644 src/app/api/projects/[projectId]/workstreams/[workstreamId]/route.ts create mode 100644 src/app/api/projects/[projectId]/workstreams/authorize.ts create mode 100644 src/app/api/projects/[projectId]/workstreams/cascade.test.ts create mode 100644 src/app/api/projects/[projectId]/workstreams/cascade.ts create mode 100644 src/app/api/projects/[projectId]/workstreams/queries.ts create mode 100644 src/app/api/projects/[projectId]/workstreams/route.test.ts create mode 100644 src/app/api/projects/[projectId]/workstreams/route.ts diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/route.test.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/route.test.ts new file mode 100644 index 00000000..b1b20497 --- /dev/null +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/route.test.ts @@ -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) { + 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(); + }); +}); diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/route.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/route.ts new file mode 100644 index 00000000..d1f935bb --- /dev/null +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/route.ts @@ -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 }); +} diff --git a/src/app/api/projects/[projectId]/workstreams/authorize.ts b/src/app/api/projects/[projectId]/workstreams/authorize.ts new file mode 100644 index 00000000..130bf18d --- /dev/null +++ b/src/app/api/projects/[projectId]/workstreams/authorize.ts @@ -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 { + 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 }; +} diff --git a/src/app/api/projects/[projectId]/workstreams/cascade.test.ts b/src/app/api/projects/[projectId]/workstreams/cascade.test.ts new file mode 100644 index 00000000..1ff58f54 --- /dev/null +++ b/src/app/api/projects/[projectId]/workstreams/cascade.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/app/api/projects/[projectId]/workstreams/cascade.ts b/src/app/api/projects/[projectId]/workstreams/cascade.ts new file mode 100644 index 00000000..583e0902 --- /dev/null +++ b/src/app/api/projects/[projectId]/workstreams/cascade.ts @@ -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" }; +} diff --git a/src/app/api/projects/[projectId]/workstreams/queries.ts b/src/app/api/projects/[projectId]/workstreams/queries.ts new file mode 100644 index 00000000..99b42ca3 --- /dev/null +++ b/src/app/api/projects/[projectId]/workstreams/queries.ts @@ -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, +): Promise { + return db + .select({ + id: projectWorkstream.id, + workstreamId: projectWorkstream.workstreamId, + stages: sql`count(distinct ${projectWorkstreamStage.id})::int`, + evidenceRequirements: sql`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`, + contractors: sql`count(distinct ${projectWorkstreamContractor.id})::int`, + workOrders: sql`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 { + 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 { + 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 { + 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)); + }); +} diff --git a/src/app/api/projects/[projectId]/workstreams/route.test.ts b/src/app/api/projects/[projectId]/workstreams/route.test.ts new file mode 100644 index 00000000..1fd78f5c --- /dev/null +++ b/src/app/api/projects/[projectId]/workstreams/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/projects/[projectId]/workstreams/route.ts b/src/app/api/projects/[projectId]/workstreams/route.ts new file mode 100644 index 00000000..3a03e1a6 --- /dev/null +++ b/src/app/api/projects/[projectId]/workstreams/route.ts @@ -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; + 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 }, + ); + } +} From 40bc9a6a64b7af60e50ee5a8e74b3d70355e7f64 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 10:13:01 +0000 Subject: [PATCH 2/3] feat(ara-projects): workstream selection setup screen (#410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card grid of the seeded workstreams at /projects/[projectId]/setup/workstreams, rendered from the workstream reference table (#407) — never a hard-coded list. The screen is revisitable, not one-shot: every toggle persists straight to project_workstream, and the current selection is read back from the DB on each visit. `?from=hub` is the only difference between the first-run wizard and a later edit from the setup hub (#444) — it swaps Continue for a return to the hub. The server decides deselects. The grid always tries the plain DELETE and reacts to the 409: `requiresConfirmation` raises the confirm dialog, `blocked` (work orders exist) refuses. The dependent counts on the cards signpost only; they never gate. canManage comes from @/lib/projects/authz, so a contractor sees the setup read-only rather than a broken editor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../setup/workstreams/WorkstreamCard.tsx | 129 ++++++++ .../workstreams/WorkstreamSelectionGrid.tsx | 284 ++++++++++++++++++ .../[projectId]/setup/workstreams/page.tsx | 81 +++++ 3 files changed, 494 insertions(+) create mode 100644 src/app/projects/[projectId]/setup/workstreams/WorkstreamCard.tsx create mode 100644 src/app/projects/[projectId]/setup/workstreams/WorkstreamSelectionGrid.tsx create mode 100644 src/app/projects/[projectId]/setup/workstreams/page.tsx diff --git a/src/app/projects/[projectId]/setup/workstreams/WorkstreamCard.tsx b/src/app/projects/[projectId]/setup/workstreams/WorkstreamCard.tsx new file mode 100644 index 00000000..22785df6 --- /dev/null +++ b/src/app/projects/[projectId]/setup/workstreams/WorkstreamCard.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { + AlertTriangle, + AppWindow, + Bath, + CheckCircle2, + DoorOpen, + HardHat, + Home, + Layers, + Loader2, + Lock, + Paintbrush, + Plus, + Utensils, + Zap, + type LucideIcon, +} from "lucide-react"; +import { describeDependents } from "@/app/api/projects/[projectId]/workstreams/cascade"; +import type { WorkstreamOption } from "@/app/api/projects/[projectId]/workstreams/queries"; + +/** + * Presentation for one workstream in the setup grid (issue #410). + * + * The card is a toggle: `aria-pressed` carries the selected state so the grid + * is navigable without relying on the colour change alone. + */ + +/** + * Illustration per seeded workstream. Purely decorative — the workstream list + * itself always comes from the `workstream` reference table (#407), and an + * unrecognised name simply falls back to the generic icon. + */ +const ICONS: Record = { + Windows: AppWindow, + Doors: DoorOpen, + Roofs: Home, + Kitchens: Utensils, + Bathrooms: Bath, + Asbestos: HardHat, + "Electrical heating": Zap, + "Cyclical decorations": Paintbrush, +}; + +export function WorkstreamCard({ + option, + disabled, + pending, + onToggle, +}: { + option: WorkstreamOption; + /** Read-only viewer, or another card is mid-flight. */ + disabled: boolean; + pending: boolean; + onToggle: () => void; +}) { + const Icon = ICONS[option.name] ?? Layers; + const locked = option.dependents.workOrders > 0; + const configured = describeDependents(option.dependents); + + return ( + + ); +} diff --git a/src/app/projects/[projectId]/setup/workstreams/WorkstreamSelectionGrid.tsx b/src/app/projects/[projectId]/setup/workstreams/WorkstreamSelectionGrid.tsx new file mode 100644 index 00000000..a736db01 --- /dev/null +++ b/src/app/projects/[projectId]/setup/workstreams/WorkstreamSelectionGrid.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, ArrowRight, Info } from "lucide-react"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { ConfirmDialog } from "@/app/components/ConfirmDialog"; +import type { WorkstreamOption } from "@/app/api/projects/[projectId]/workstreams/queries"; +import { WorkstreamCard } from "./WorkstreamCard"; + +/** + * The workstream-selection grid (issue #410). + * + * Every toggle persists immediately — there is no local draft to lose — so the + * screen behaves the same whether it is step 2 of the first-run wizard or a + * later visit from the setup hub (#444). The only difference between the two + * is where the footer sends you. + * + * The server is the authority on whether a deselect may proceed: the grid + * always tries the plain DELETE first and reacts to the 409 it gets back + * (`requiresConfirmation` → dialog, `blocked` → refusal). The dependent counts + * on the cards are for signposting, never for gating. + */ + +/** Which way the user arrived, and therefore where the footer leads. */ +export type SetupEntryMode = "wizard" | "hub"; + +export interface WorkstreamGridData { + workstreams: WorkstreamOption[]; + canManage: boolean; +} + +export function workstreamsQueryKey(projectId: string) { + return ["projects", projectId, "workstreams"]; +} + +type Notice = { tone: "error" | "info"; text: string }; + +type DeselectOutcome = + | { kind: "removed" } + | { kind: "blocked"; reason: string } + | { kind: "needs-confirmation"; reason: string }; + +export function WorkstreamSelectionGrid({ + projectId, + mode, + initialData, +}: { + projectId: string; + mode: SetupEntryMode; + initialData: WorkstreamGridData; +}) { + const router = useRouter(); + const queryClient = useQueryClient(); + + const [notice, setNotice] = useState(null); + const [pendingDeselect, setPendingDeselect] = useState<{ + option: WorkstreamOption; + reason: string; + } | null>(null); + + const { data } = useQuery({ + queryKey: workstreamsQueryKey(projectId), + queryFn: async (): Promise => { + const res = await fetch(`/api/projects/${projectId}/workstreams`); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't load workstreams"); + return body; + }, + initialData, + }); + + const refresh = () => + queryClient.invalidateQueries({ queryKey: workstreamsQueryKey(projectId) }); + + const select = useMutation({ + mutationFn: async (option: WorkstreamOption) => { + const res = await fetch(`/api/projects/${projectId}/workstreams`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workstreamId: option.id }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error ?? "Couldn't add the workstream"); + }, + onSuccess: () => { + setNotice(null); + refresh(); + }, + onError: (err: Error) => setNotice({ tone: "error", text: err.message }), + }); + + const deselect = useMutation({ + mutationFn: async ({ + option, + confirmed, + }: { + option: WorkstreamOption; + confirmed: boolean; + }): Promise => { + const res = await fetch( + `/api/projects/${projectId}/workstreams/${option.id}${ + confirmed ? "?confirm=true" : "" + }`, + { method: "DELETE" }, + ); + const body = await res.json().catch(() => ({})); + + // 409 is not a failure — it is the cascade rules asking a question + // (confirm) or refusing outright (work orders exist). + if (res.status === 409) { + const reason = body.error ?? "This workstream can't be removed."; + return body.blocked + ? { kind: "blocked", reason } + : { kind: "needs-confirmation", reason }; + } + if (!res.ok) { + throw new Error(body.error ?? "Couldn't remove the workstream"); + } + return { kind: "removed" }; + }, + onSuccess: (outcome, { option }) => { + if (outcome.kind === "needs-confirmation") { + setPendingDeselect({ option, reason: outcome.reason }); + return; + } + setPendingDeselect(null); + setNotice( + outcome.kind === "blocked" + ? { tone: "error", text: outcome.reason } + : null, + ); + // Even a refusal refreshes: the counts that caused it may have moved. + refresh(); + }, + onError: (err: Error) => { + setPendingDeselect(null); + setNotice({ tone: "error", text: err.message }); + }, + }); + + const busyId = select.isLoading + ? select.variables?.id + : deselect.isLoading + ? deselect.variables?.option.id + : undefined; + + const { workstreams, canManage } = data; + const selectedCount = workstreams.filter((w) => w.selected).length; + + function toggle(option: WorkstreamOption) { + if (!canManage || busyId) return; + setNotice(null); + if (option.selected) { + deselect.mutate({ option, confirmed: false }); + } else { + select.mutate(option); + } + } + + return ( +
+ {!canManage && ( +

+ + You can see this project’s setup, but only the client + organisation and Domna can change it. +

+ )} + + {notice && ( +

+ {notice.text} +

+ )} + + {workstreams.length === 0 ? ( +
+

+ No workstreams available +

+

+ The workstream reference data hasn’t been seeded for this + environment yet. +

+
+ ) : ( +
+ {workstreams.map((option) => ( + toggle(option)} + /> + ))} +
+ )} + +
+ + +
+ + {selectedCount} selected + {canManage && " · changes save as you go"} + + {mode === "hub" ? ( + + ) : selectedCount === 0 ? ( + // `asChild` renders an anchor, which ignores `disabled` — so an + // un-followable Continue has to be a real button. + + ) : ( + + )} +
+
+ + { + if (!open) setPendingDeselect(null); + }} + title={ + pendingDeselect + ? `Remove ${pendingDeselect.option.name}?` + : "Remove workstream?" + } + description={pendingDeselect?.reason ?? ""} + confirmLabel="Remove workstream" + destructive + isPending={deselect.isLoading} + onConfirm={() => { + if (pendingDeselect) { + deselect.mutate({ option: pendingDeselect.option, confirmed: true }); + } + }} + /> +
+ ); +} diff --git a/src/app/projects/[projectId]/setup/workstreams/page.tsx b/src/app/projects/[projectId]/setup/workstreams/page.tsx new file mode 100644 index 00000000..1e9447d7 --- /dev/null +++ b/src/app/projects/[projectId]/setup/workstreams/page.tsx @@ -0,0 +1,81 @@ +import { notFound } from "next/navigation"; +import { requireProjectAccess } from "../../../authz"; +import { authorizeWorkstreamRequest } from "@/app/api/projects/[projectId]/workstreams/authorize"; +import { listWorkstreamOptions } from "@/app/api/projects/[projectId]/workstreams/queries"; +import { + WorkstreamSelectionGrid, + type SetupEntryMode, +} from "./WorkstreamSelectionGrid"; + +export const metadata = { + title: "Select workstreams | Ara", +}; + +/** + * Workstream selection — setup step 2 (issue #410). + * + * Independently addressable, and deliberately so: it is step 2 of the + * first-run wizard *and* the screen the setup hub (#444) links to when the + * selection needs changing later. `?from=hub` is the only difference between + * the two — it swaps the footer's Continue for a return to the hub. Everything + * else, including the current selection, is read back from the database on + * every visit, so a revisit is never a blank slate. + * + * Adding a workstream later leaves it needing stages (#411) and a contractor + * (#413); surfacing that is the readiness helper's job (#414), not this + * screen's. + */ +export default async function SelectWorkstreamsPage(props: { + params: Promise<{ projectId: string }>; + searchParams: Promise<{ from?: string }>; +}) { + const { projectId } = await props.params; + const { from } = await props.searchParams; + + // Session guard first (defence in depth behind src/middleware.ts), then the + // real per-project rule from #408. + await requireProjectAccess(projectId); + const auth = await authorizeWorkstreamRequest(projectId, "view"); + if (!auth.ok) notFound(); + + const workstreams = await listWorkstreamOptions(auth.projectId); + const mode: SetupEntryMode = from === "hub" ? "hub" : "wizard"; + + return ( +
+
+ {mode === "wizard" ? ( +
+ + Step 2 of 5 + + + + +
+ ) : ( +

+ Project setup +

+ )} +

+ Select workstreams +

+

+ Choose the categories of work this project delivers. Each one gets its + own stages, evidence requirements and contractors — you can change the + selection later from project setup. +

+
+ + +
+ ); +} From 4366835b1e97b811b987c38f51650a25ef1c11db Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 10:14:37 +0000 Subject: [PATCH 3/3] fix(ara-projects): copy the empty-dependents constant per option (#410) Every unselected card was handed the same NO_DEPENDENTS object; a caller mutating one would have moved them all. Spread it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/projects/[projectId]/workstreams/queries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/projects/[projectId]/workstreams/queries.ts b/src/app/api/projects/[projectId]/workstreams/queries.ts index 99b42ca3..4032ba2d 100644 --- a/src/app/api/projects/[projectId]/workstreams/queries.ts +++ b/src/app/api/projects/[projectId]/workstreams/queries.ts @@ -127,7 +127,7 @@ export async function listWorkstreamOptions( contractors: selection.contractors, workOrders: selection.workOrders, } - : NO_DEPENDENTS, + : { ...NO_DEPENDENTS }, }; }); }