From bbc2843fdfaefc35d4fc187a177c06016241803a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 12:38:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(ara-projects):=20work-orders=20API=20?= =?UTF-8?q?=E2=80=94=20filters,=20keyset=20pagination,=20shared=20derivati?= =?UTF-8?q?ons=20(#419)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/projects/[projectId]/work-orders serves one page of the admin work-orders table: workstream / contractor / stage / overdue-only / missing-required-evidence filters, keyset-paginated by work_order.id so a cursor stays meaningful under any filter combination. The rules stay in one place. Every derived filter is split at its AND, the way #418's dashboard repository splits it, and the TypeScript half crosses into SQL as data rather than as a re-implemented predicate: - overdue: buildStageLadders classifies the project's stages in JS and stageIdsByProgress("complete") hands the terminal ids to the query; SQL only counts forecast_end < asOf. - missing evidence: requiredEvidenceRequirementIds decides which requirement applies at which stage and hands over an explicit (stage_id, requirement_id) list. Row badges come from toWorkOrderRow, which calls isOverdue / progressOfWorkOrder / evidenceCompleteness directly, so the table and the dashboard reconcile by construction. Authz is canManageProject (internal + client-org). A contractor gets 403 rather than the whole programme; row scoping is a later issue. Tests mock @/app/db/db and open no connection. --- .../[projectId]/work-orders/authorize.ts | 63 +++ .../[projectId]/work-orders/filters.test.ts | 150 +++++++ .../[projectId]/work-orders/filters.ts | 204 +++++++++ .../[projectId]/work-orders/queries.test.ts | 209 +++++++++ .../[projectId]/work-orders/queries.ts | 421 ++++++++++++++++++ .../[projectId]/work-orders/route.test.ts | 194 ++++++++ .../projects/[projectId]/work-orders/route.ts | 44 ++ .../[projectId]/work-orders/rows.test.ts | 150 +++++++ .../projects/[projectId]/work-orders/rows.ts | 135 ++++++ 9 files changed, 1570 insertions(+) create mode 100644 src/app/api/projects/[projectId]/work-orders/authorize.ts create mode 100644 src/app/api/projects/[projectId]/work-orders/filters.test.ts create mode 100644 src/app/api/projects/[projectId]/work-orders/filters.ts create mode 100644 src/app/api/projects/[projectId]/work-orders/queries.test.ts create mode 100644 src/app/api/projects/[projectId]/work-orders/queries.ts create mode 100644 src/app/api/projects/[projectId]/work-orders/route.test.ts create mode 100644 src/app/api/projects/[projectId]/work-orders/route.ts create mode 100644 src/app/api/projects/[projectId]/work-orders/rows.test.ts create mode 100644 src/app/api/projects/[projectId]/work-orders/rows.ts diff --git a/src/app/api/projects/[projectId]/work-orders/authorize.ts b/src/app/api/projects/[projectId]/work-orders/authorize.ts new file mode 100644 index 00000000..29f5ead4 --- /dev/null +++ b/src/app/api/projects/[projectId]/work-orders/authorize.ts @@ -0,0 +1,63 @@ +/** + * Request authorization for the work-orders endpoint (issue #419). + * + * Wiring only, mirroring `../workstreams/authorize.ts`: the session comes from + * NextAuth, the facts from the projects authz repository, and every *decision* + * from `@/lib/projects/authz` (#408). No permission rule is re-implemented here. + * + * A project the caller cannot see is reported as 404, never 403, so project + * existence does not leak outside its organisation. + * + * Scoping note: this endpoint is the **admin** table — internal and client-org + * members, per the issue's acceptance criteria — so it requires + * `canManageProject`. A contractor resolves a role on the project and would + * pass `canViewProject`, but must not see other contractors' work orders; + * narrowing the rows to their own assignments is a later issue, and until it + * lands the honest answer for a contractor is 403 rather than a table showing + * them the whole programme. + */ +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 AuthorizeResult = + | { ok: true; projectId: bigint } + | ({ ok: false } & AuthorizeFailure); + +/** Resolve the caller's standing on `projectIdParam` for the admin table. */ +export async function authorizeWorkOrdersRequest( + projectIdParam: string, +): 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 }; + } + if (!canManageProject(user, project)) { + return { ok: false, error: "Forbidden", status: 403 }; + } + + return { ok: true, projectId }; +} diff --git a/src/app/api/projects/[projectId]/work-orders/filters.test.ts b/src/app/api/projects/[projectId]/work-orders/filters.test.ts new file mode 100644 index 00000000..144a1741 --- /dev/null +++ b/src/app/api/projects/[projectId]/work-orders/filters.test.ts @@ -0,0 +1,150 @@ +/** + * The work-orders query string (issue #419). + * + * Pure module, pure test: no database, no network. The round trip matters most + * — the client builds the params and the server parses them, so anything that + * survives `workOrderSearchParams` must come back out of `parseWorkOrderQuery` + * unchanged, or the table and its filter bar silently disagree. + */ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + NO_FILTERS, + hasActiveFilters, + parseWorkOrderQuery, + workOrderSearchParams, + type WorkOrderFilterSelection, +} from "./filters"; + +const CONTRACTOR = "22222222-2222-2222-2222-222222222222"; + +const parse = (query: string) => parseWorkOrderQuery(new URLSearchParams(query)); + +describe("parseWorkOrderQuery", () => { + it("treats an empty query string as no filters and the default page size", () => { + const result = parse(""); + expect(result).toEqual({ + ok: true, + query: { + workstreamId: null, + contractorOrganisationId: null, + stageId: null, + overdueOnly: false, + missingEvidence: false, + cursor: null, + limit: DEFAULT_PAGE_SIZE, + }, + }); + }); + + it("parses every filter at once — they compose rather than override", () => { + const result = parse( + `workstreamId=10&stageId=102&contractorOrganisationId=${CONTRACTOR}` + + "&overdue=true&missingEvidence=true&cursor=812&limit=25", + ); + expect(result).toEqual({ + ok: true, + query: { + workstreamId: 10n, + contractorOrganisationId: CONTRACTOR, + stageId: 102n, + overdueOnly: true, + missingEvidence: true, + cursor: 812n, + limit: 25, + }, + }); + }); + + it("rejects a non-numeric id rather than silently dropping the filter", () => { + // A dropped filter shows a table that does not match the controls above it. + expect(parse("workstreamId=windows")).toEqual({ + ok: false, + error: "Invalid workstreamId", + }); + expect(parse("stageId=-1")).toEqual({ ok: false, error: "Invalid stageId" }); + expect(parse("cursor=0")).toEqual({ ok: false, error: "Invalid cursor" }); + }); + + it("rejects a contractor id that is not a uuid", () => { + expect(parse("contractorOrganisationId=42")).toEqual({ + ok: false, + error: "Invalid contractorOrganisationId", + }); + }); + + it("rejects a boolean it does not recognise, to catch a typo'd flag", () => { + expect(parse("overdue=yes")).toEqual({ ok: false, error: "Invalid overdue" }); + expect(parse("missingEvidence=on")).toEqual({ + ok: false, + error: "Invalid missingEvidence", + }); + }); + + it("accepts the falsey spellings of a flag", () => { + expect(parse("overdue=false")).toMatchObject({ + ok: true, + query: { overdueOnly: false }, + }); + expect(parse("overdue=1")).toMatchObject({ + ok: true, + query: { overdueOnly: true }, + }); + }); + + it("refuses to stream the whole programme in one response", () => { + expect(parse(`limit=${MAX_PAGE_SIZE}`)).toMatchObject({ ok: true }); + expect(parse(`limit=${MAX_PAGE_SIZE + 1}`)).toEqual({ + ok: false, + error: "Invalid limit", + }); + expect(parse("limit=0")).toEqual({ ok: false, error: "Invalid limit" }); + }); + + it("rejects a SQL fragment in an id parameter", () => { + expect(parse("workstreamId=1%3B+DROP+TABLE+work_order")).toEqual({ + ok: false, + error: "Invalid workstreamId", + }); + }); +}); + +describe("workOrderSearchParams", () => { + const selection: WorkOrderFilterSelection = { + workstreamId: "10", + contractorOrganisationId: CONTRACTOR, + stageId: "102", + overdueOnly: true, + missingEvidence: true, + }; + + it("round-trips a full selection back through the parser", () => { + const params = workOrderSearchParams(selection, { cursor: "812", limit: 25 }); + expect(parseWorkOrderQuery(params)).toEqual({ + ok: true, + query: { + workstreamId: 10n, + contractorOrganisationId: CONTRACTOR, + stageId: 102n, + overdueOnly: true, + missingEvidence: true, + cursor: 812n, + limit: 25, + }, + }); + }); + + it("omits everything falsey, so the unfiltered page has a stable cache key", () => { + expect(workOrderSearchParams(NO_FILTERS).toString()).toBe(""); + expect(workOrderSearchParams(NO_FILTERS, { cursor: null }).toString()).toBe(""); + }); +}); + +describe("hasActiveFilters", () => { + it("is false only when nothing at all is narrowed", () => { + expect(hasActiveFilters(NO_FILTERS)).toBe(false); + expect(hasActiveFilters({ ...NO_FILTERS, overdueOnly: true })).toBe(true); + expect(hasActiveFilters({ ...NO_FILTERS, stageId: "1" })).toBe(true); + }); +}); diff --git a/src/app/api/projects/[projectId]/work-orders/filters.ts b/src/app/api/projects/[projectId]/work-orders/filters.ts new file mode 100644 index 00000000..a012c88a --- /dev/null +++ b/src/app/api/projects/[projectId]/work-orders/filters.ts @@ -0,0 +1,204 @@ +/** + * The work-orders table's query string, in one place (issue #419). + * + * The filter bar, the `useQuery` key, the fetch URL and the route handler all + * have to agree on exactly what `?overdue=true&cursor=812` means. They agree + * because they all go through this module: the client *builds* the search + * params with `workOrderSearchParams`, the handler *parses* them back with + * `parseWorkOrderQuery`, and neither reads a raw parameter name of its own. + * + * Pure by construction — no database client, no React — so it is safe to import + * from a client component and it unit-tests without a connection. + * + * ## Two representations, deliberately + * + * - **Selection** (`WorkOrderFilterSelection`) is what the UI holds: ids as + * strings, because bigint ids do not survive JSON and a `