mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
Merge pull request #447 from Hestia-Homes/issue-410-wizard-workstream-selection
feat(ara-projects): setup wizard 2/5 — workstream selection
This commit is contained in:
commit
08b6c28f87
11 changed files with 1609 additions and 0 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
71
src/app/api/projects/[projectId]/workstreams/authorize.ts
Normal file
71
src/app/api/projects/[projectId]/workstreams/authorize.ts
Normal 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 };
|
||||
}
|
||||
124
src/app/api/projects/[projectId]/workstreams/cascade.test.ts
Normal file
124
src/app/api/projects/[projectId]/workstreams/cascade.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
105
src/app/api/projects/[projectId]/workstreams/cascade.ts
Normal file
105
src/app/api/projects/[projectId]/workstreams/cascade.ts
Normal 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" };
|
||||
}
|
||||
248
src/app/api/projects/[projectId]/workstreams/queries.ts
Normal file
248
src/app/api/projects/[projectId]/workstreams/queries.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
228
src/app/api/projects/[projectId]/workstreams/route.test.ts
Normal file
228
src/app/api/projects/[projectId]/workstreams/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
src/app/api/projects/[projectId]/workstreams/route.ts
Normal file
72
src/app/api/projects/[projectId]/workstreams/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, LucideIcon> = {
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={option.selected}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
data-testid={`workstream-card-${option.id}`}
|
||||
data-selected={option.selected}
|
||||
className={[
|
||||
"group relative flex h-[150px] flex-col rounded-lg border-2 p-4 text-left transition-all",
|
||||
option.selected
|
||||
? "border-indigo-600 bg-white shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-md",
|
||||
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="absolute right-3 top-3">
|
||||
{pending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-indigo-600" />
|
||||
) : option.selected ? (
|
||||
<CheckCircle2
|
||||
className="h-5 w-5 text-indigo-600"
|
||||
data-testid={`workstream-selected-${option.id}`}
|
||||
/>
|
||||
) : (
|
||||
<Plus className="h-5 w-5 text-gray-300 transition-colors group-hover:text-gray-500" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"mb-2 flex h-9 w-9 items-center justify-center rounded",
|
||||
option.selected
|
||||
? "bg-indigo-50 text-indigo-700"
|
||||
: "bg-gray-100 text-gray-500",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
|
||||
<span className="text-base font-bold leading-tight text-gray-900">
|
||||
{option.name}
|
||||
</span>
|
||||
<span className="mt-1 line-clamp-2 text-xs text-gray-500">
|
||||
{option.description}
|
||||
</span>
|
||||
|
||||
<span className="mt-auto flex flex-wrap items-center gap-1.5 pt-2">
|
||||
{locked && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||
data-testid={`workstream-locked-${option.id}`}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
{option.dependents.workOrders}{" "}
|
||||
{option.dependents.workOrders === 1 ? "work order" : "work orders"}
|
||||
</span>
|
||||
)}
|
||||
{!locked && configured && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
|
||||
<AlertTriangle className="h-3 w-3 text-gray-400" />
|
||||
{configured}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Notice | null>(null);
|
||||
const [pendingDeselect, setPendingDeselect] = useState<{
|
||||
option: WorkstreamOption;
|
||||
reason: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: workstreamsQueryKey(projectId),
|
||||
queryFn: async (): Promise<WorkstreamGridData> => {
|
||||
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<DeselectOutcome> => {
|
||||
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 (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{!canManage && (
|
||||
<p
|
||||
className="mb-5 flex items-start gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600"
|
||||
data-testid="workstreams-read-only"
|
||||
>
|
||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400" />
|
||||
You can see this project’s setup, but only the client
|
||||
organisation and Domna can change it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<p
|
||||
className={[
|
||||
"mb-5 rounded-lg border px-4 py-3 text-sm",
|
||||
notice.tone === "error"
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: "border-gray-200 bg-gray-50 text-gray-600",
|
||||
].join(" ")}
|
||||
role="alert"
|
||||
data-testid="workstreams-notice"
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{workstreams.length === 0 ? (
|
||||
<div
|
||||
className="rounded-2xl border-2 border-dashed border-gray-200 py-16 text-center"
|
||||
data-testid="workstreams-empty"
|
||||
>
|
||||
<p className="mb-1 text-base font-semibold text-gray-700">
|
||||
No workstreams available
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
The workstream reference data hasn’t been seeded for this
|
||||
environment yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mb-8 grid flex-1 content-start grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
data-testid="workstream-grid"
|
||||
>
|
||||
{workstreams.map((option) => (
|
||||
<WorkstreamCard
|
||||
key={option.id}
|
||||
option={option}
|
||||
disabled={!canManage || (Boolean(busyId) && busyId !== option.id)}
|
||||
pending={busyId === option.id}
|
||||
onToggle={() => toggle(option)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-gray-200 pt-6">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-gray-500" data-testid="selected-count">
|
||||
{selectedCount} selected
|
||||
{canManage && " · changes save as you go"}
|
||||
</span>
|
||||
{mode === "hub" ? (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/projects/${projectId}/settings`}
|
||||
data-testid="workstreams-back-to-setup"
|
||||
>
|
||||
Back to setup
|
||||
</Link>
|
||||
</Button>
|
||||
) : selectedCount === 0 ? (
|
||||
// `asChild` renders an anchor, which ignores `disabled` — so an
|
||||
// un-followable Continue has to be a real button.
|
||||
<Button disabled data-testid="workstreams-continue">
|
||||
Continue
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/projects/${projectId}/setup/stages`}
|
||||
data-testid="workstreams-continue"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingDeselect)}
|
||||
onOpenChange={(open) => {
|
||||
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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/app/projects/[projectId]/setup/workstreams/page.tsx
Normal file
81
src/app/projects/[projectId]/setup/workstreams/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="mx-auto flex min-h-[70vh] w-full max-w-6xl flex-col px-6 py-10">
|
||||
<div className="mb-8 max-w-3xl">
|
||||
{mode === "wizard" ? (
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||
Step 2 of 5
|
||||
</span>
|
||||
<span className="h-1 w-full max-w-[200px] overflow-hidden rounded-full bg-gray-100">
|
||||
<span className="block h-full w-2/5 rounded-full bg-indigo-600" />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||
Project setup
|
||||
</p>
|
||||
)}
|
||||
<h1
|
||||
className="text-3xl font-extrabold tracking-tight text-gray-900"
|
||||
data-testid="workstreams-heading"
|
||||
>
|
||||
Select workstreams
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<WorkstreamSelectionGrid
|
||||
projectId={projectId}
|
||||
mode={mode}
|
||||
initialData={{ workstreams, canManage: auth.canManage }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue