diff --git a/CLAUDE.md b/CLAUDE.md
index 5fd0594b..c87b3c04 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,6 +10,29 @@
- **Avoid `useEffect` and `useMemo`.** Derive values inline, prefer Server Components + Route Handlers, prefer event handlers. If a hook is genuinely the only option, flag it and ask before using it.
- **Use TanStack Query (`@tanstack/react-query`), not raw `fetch`, for client-side HTTP.** Reads use `useQuery`; writes use `useMutation`. This project is on **v4** — note that `refetchInterval`'s callback signature is `(data, query)`, not v5's `(query)`.
+## Dates
+
+- **`dd/mm/yyyy` everywhere a person reads or types a date.** This is a UK
+ product. `03/09/2026` is 3 September; rendering it as 3 March is silently
+ wrong data, not a cosmetic bug.
+- **Never ``.** Its format follows the *browser's* locale,
+ so a US-configured browser shows `mm/dd/yyyy` and no attribute overrides it.
+ Use `DateInput` (`@/app/components/DateInput`) instead — it masks to
+ `dd/mm/yyyy`, carries a calendar picker, and its `value`/`onChange` stay
+ `YYYY-MM-DD`.
+- **`YYYY-MM-DD` is the only format that crosses a boundary** — form state,
+ request bodies, Drizzle `date` columns. The display format stops at the
+ input's edge. Parsing/formatting helpers are in `@/utils/dates`; for output,
+ pass `"en-GB"` to `toLocaleDateString` / `Intl.DateTimeFormat`.
+- See [ADR-0019](./docs/adr/0019-uk-date-format-in-form-inputs.md).
+
+## Utilities
+
+- **`src/utils/`** is for generic helpers — no domain knowledge (e.g.
+ `dates.ts`). **`src/app/utils.ts`** is for company-specific ones (EPC bands,
+ SAP points, portfolio ratings). `src/lib/utils.ts` is vendored Tremor/shadcn
+ class helpers — don't add to it.
+
## Next.js 15 route handlers
- `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring.
diff --git a/CONTEXT.md b/CONTEXT.md
index 62859a70..62a4ee06 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -161,13 +161,17 @@ A category of works (Windows, Doors, Roofs, Electrical, Heating, …) — refere
_Avoid_: measure (Measure is taken by the Scenarios domain), trade, work type
**Stage**:
-One step in a Project Workstream's ordered ladder (`project_workstream_stage`, positioned by `order`). Each Project Workstream owns its own ladder — different workstreams may follow different workflows. The v1 default ladder is Ordered / In progress / Completed / Charged / Closed, and the terminal stage is the one with the highest `order`. A Work order's stage FK (`work_order.project_workstream_stage_id`) is the **only lifecycle mechanism in v1** — `work_order.status`, `work_order.priority` and `project_workstream.current_stage_id` exist in the schema but are untouched.
+One step in a Project Workstream's ordered ladder (`project_workstream_stage`, positioned by `order`). Each Project Workstream owns its own ladder — different workstreams may follow different workflows. The v1 default ladder is Ordered / In progress / Completed / Charged / Closed, and the terminal stage is the one with the highest `order`. A Work order's stage FK (`work_order.project_workstream_stage_id`) is the **only lifecycle mechanism in v1** — `work_order.status` and `project_workstream.current_stage_id` exist in the schema but are untouched. `work_order.priority` is now used, but it is not a lifecycle mechanism: see **Priority**.
_Avoid_: status (the unused text column), phase, step
**Work order**:
One unit of work issued to a contractor: a (Property, Project Workstream Stage, contractor organisation, unique reference, forecast end date) row in `work_order`. Its position in the workflow is its Stage FK (see **Stage**).
_Avoid_: job, ticket, task (a **Task** is the BulkUpload orchestration handle)
+**Priority**:
+A **flag on a Work order** (`work_order.priority`, boolean), meaning "look at this one first". It is a *stored* fact — someone marks it — and so is deliberately independent of **Overdue**, which is derived from the forecast end date and the Stage ladder: a flagged work order may be well inside its dates, and a late one nobody flagged is not a priority. Not a scale: there are no priority *levels* in v1, only flagged and not.
+_Avoid_: urgency, severity, high/medium/low (there are no levels), escalation
+
**Evidence requirement** / **Evidence**:
An **Evidence requirement** (`project_workstream_evidence_requirement`) is configuration: a required `file_type` (the existing enum) per Project Workstream, optionally narrowed to a single Stage. **Evidence** is a submission: an `uploaded_files` row with `file_source = 'projects'`, linked to its Work order (`work_order_id`) and optionally to the requirement it satisfies. Evidence is **advisory-only in v1** — badges and counts, never a gate: it does not block stage transitions and there is no approval state.
_Avoid_: document (generic), attachment, proof
diff --git a/cypress/e2e/projects/create-project.cy.js b/cypress/e2e/projects/create-project.cy.js
new file mode 100644
index 00000000..25e38639
--- /dev/null
+++ b/cypress/e2e/projects/create-project.cy.js
@@ -0,0 +1,235 @@
+/**
+ * Ara Projects — projects list, empty state and create-project modal (#409)
+ *
+ * Two groups, because one of them writes:
+ *
+ * 1. The UI group stubs POST /api/projects with cy.intercept. It exercises
+ * the empty state, the modal, client-side validation and the navigation
+ * to workstream selection without touching the database, so it is safe to
+ * run anywhere.
+ *
+ * 2. The end-to-end group performs a real create and is the acceptance
+ * criterion's "create a project end-to-end". It INSERTs, so it is gated
+ * behind CYPRESS_ARA_PROJECTS_E2E_WRITES and must only ever be pointed at
+ * a non-production database. It also needs the #407 reference-data
+ * migration (0274) applied, or the project-type select will be empty and
+ * the page will not offer the CTA at all.
+ *
+ * Like the shell spec, this uses `cy.login` (cypress/support/commands.ts) to
+ * mint a real next-auth JWE cookie, so NEXTAUTH_JWT_SECRET must be set.
+ *
+ * NOT YET RUN. The environment this was written in has DATABASE_URL pointing at
+ * production, so Cypress was deliberately not executed. Treat the selectors and
+ * assertions below as unverified until someone runs them against a scratch DB.
+ */
+
+const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET");
+const E2E_WRITES = Cypress.env("ARA_PROJECTS_E2E_WRITES");
+
+const INTERNAL_USER = {
+ email: "dev@domna.homes",
+ name: "Internal User",
+ onboarded: true,
+ sub: "cypress-internal",
+};
+
+describe("Ara Projects — create project (stubbed)", function () {
+ beforeEach(function () {
+ if (!JWT_SECRET) {
+ cy.log("NEXTAUTH_JWT_SECRET not set — skipping");
+ this.skip();
+ }
+ cy.login(INTERNAL_USER);
+ });
+
+ it("offers the Create project CTA from the list", function () {
+ cy.visit("/projects");
+
+ // The CTA sits inside the welcome card when there are no projects, and in
+ // the page header once there are — either way there is exactly one.
+ cy.get("[data-testid=create-project-trigger]").should("be.visible").click();
+ cy.get("[data-testid=create-project-form]").should("be.visible");
+ cy.contains("Project details").should("be.visible");
+ });
+
+ it("shows the welcome empty state when the user has no visible projects", function () {
+ // Only meaningful on an instance with no projects visible to this user;
+ // where there are some, the list renders instead and this is skipped.
+ cy.visit("/projects");
+ cy.get("body").then(($body) => {
+ if ($body.find("[data-testid=projects-list-empty]").length === 0) {
+ cy.log("user has visible projects — empty state not applicable");
+ return;
+ }
+ cy.contains("Welcome to Ara Projects").should("be.visible");
+ cy.contains("How setup works").should("be.visible");
+ cy.get("[data-testid=create-project-trigger]").should("be.visible");
+ });
+ });
+
+ it("refuses to submit without a name and a project type", function () {
+ cy.visit("/projects");
+ cy.get("[data-testid=create-project-trigger]").click();
+
+ cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
+ cy.get("[data-testid=create-project-submit]").click();
+
+ cy.contains("Enter a project name").should("be.visible");
+ cy.get("@createCall").should("not.have.been.called");
+ });
+
+ it("rejects an end date before the start date", function () {
+ cy.visit("/projects");
+ cy.get("[data-testid=create-project-trigger]").click();
+
+ cy.get("[data-testid=create-project-name]").type("Backwards dates");
+ cy.get("[data-testid=create-project-start-date]").type("30/09/2026");
+ cy.get("[data-testid=create-project-end-date]").type("01/03/2026");
+ cy.get("[data-testid=create-project-submit]").click();
+
+ cy.contains("The end date cannot be before the start date").should(
+ "be.visible",
+ );
+ });
+
+ it("fills the field day-first from the calendar picker", function () {
+ cy.visit("/projects");
+ cy.get("[data-testid=create-project-trigger]").click();
+
+ // Type a date first so the grid opens on a known month rather than
+ // whatever month the test happens to run in.
+ cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
+ cy.get("[data-testid=create-project-start-date-picker]").click();
+
+ // The grid is en-GB, so the week starts on Monday.
+ cy.get("[role=dialog]").within(() => {
+ cy.contains("Mo").should("be.visible");
+ cy.contains("March 2026").should("be.visible");
+ cy.get("[role=gridcell] button").contains(/^17$/).click();
+ });
+
+ cy.get("[data-testid=create-project-start-date]").should(
+ "have.value",
+ "17/03/2026",
+ );
+ });
+
+ it("formats the date as it is typed and rejects a day that does not exist", function () {
+ cy.visit("/projects");
+ cy.get("[data-testid=create-project-trigger]").click();
+
+ // The separators are supplied by the field, so eight digits are enough.
+ cy.get("[data-testid=create-project-start-date]")
+ .type("31022026")
+ .should("have.value", "31/02/2026");
+
+ cy.get("[data-testid=create-project-name]").type("Impossible date");
+ cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
+ cy.get("[data-testid=create-project-submit]").click();
+
+ // A finished-but-impossible date must not read as an empty optional field.
+ cy.contains("Enter a valid date").should("be.visible");
+ cy.get("@createCall").should("not.have.been.called");
+ });
+
+ it("navigates to workstream selection on success", function () {
+ cy.intercept("POST", "/api/projects", {
+ statusCode: 201,
+ body: { id: "4242" },
+ }).as("createProject");
+
+ cy.visit("/projects");
+ cy.get("[data-testid=create-project-trigger]").click();
+
+ cy.get("[data-testid=create-project-name]").type("Riverside retrofit 2026");
+ selectFirstOption("create-project-organisation");
+ selectFirstOption("create-project-type");
+ cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
+ cy.get("[data-testid=create-project-domna-admin-access]").click();
+ cy.get("[data-testid=create-project-submit]").click();
+
+ cy.wait("@createProject").then(({ request }) => {
+ expect(request.body.name).to.eq("Riverside retrofit 2026");
+ expect(request.body.domnaAdminAccess).to.eq(true);
+ // Typed day-first, sent ISO: the display format stops at the input, and
+ // 01/03 is 1 March rather than 3 January.
+ expect(request.body.startDate).to.eq("2026-03-01");
+ });
+
+ cy.location("pathname").should("eq", "/projects/4242/setup/workstreams");
+ });
+
+ it("surfaces the server's error message on a refusal", function () {
+ cy.intercept("POST", "/api/projects", {
+ statusCode: 403,
+ body: { error: "You cannot create a project for that organisation." },
+ }).as("createProject");
+
+ cy.visit("/projects");
+ cy.get("[data-testid=create-project-trigger]").click();
+ cy.get("[data-testid=create-project-name]").type("Not mine");
+ selectFirstOption("create-project-organisation");
+ selectFirstOption("create-project-type");
+ cy.get("[data-testid=create-project-submit]").click();
+
+ cy.wait("@createProject");
+ cy.get("[data-testid=create-project-error]")
+ .should("be.visible")
+ .and("contain", "cannot create a project for that organisation");
+ cy.location("pathname").should("eq", "/projects");
+ });
+});
+
+describe("Ara Projects — create project (end to end, writes)", function () {
+ beforeEach(function () {
+ if (!JWT_SECRET || !E2E_WRITES) {
+ cy.log(
+ "ARA_PROJECTS_E2E_WRITES not set — skipping the writing happy path",
+ );
+ this.skip();
+ }
+ cy.login(INTERNAL_USER);
+ });
+
+ it("creates a project and lands on workstream selection", function () {
+ // Unique per run so repeated runs do not collide on a name a human is
+ // eyeballing later. `project.name` has no unique constraint; this is for
+ // legibility, not correctness.
+ const name = `Cypress project ${Date.now()}`;
+
+ cy.visit("/projects");
+ cy.get("[data-testid=create-project-trigger]").click();
+
+ cy.get("[data-testid=create-project-name]").type(name);
+ selectFirstOption("create-project-organisation");
+ selectFirstOption("create-project-type");
+ cy.get("[data-testid=create-project-description]").type(
+ "Created by the Cypress happy path.",
+ );
+ cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
+ cy.get("[data-testid=create-project-end-date]").type("30/09/2026");
+ // Required for an internal user creating on behalf of an organisation they
+ // are not a member of — without it the guard refuses, by design.
+ cy.get("[data-testid=create-project-domna-admin-access]").click();
+
+ cy.get("[data-testid=create-project-submit]").click();
+
+ cy.location("pathname").should("match", /^\/projects\/\d+\/setup\/workstreams$/);
+
+ // The new project is now visible in the list, which is the visibility rule
+ // agreeing with the write that just happened.
+ cy.visit("/projects");
+ cy.get("[data-testid=projects-list]").should("exist");
+ cy.contains("[data-testid=projects-list-item]", name).should("be.visible");
+ });
+});
+
+/**
+ * Pick the first item in a shadcn/Radix `Select`. The options live in a portal
+ * outside the trigger, so this opens the trigger and then reaches for the
+ * listbox at the document root.
+ */
+function selectFirstOption(testId) {
+ cy.get(`[data-testid=${testId}]`).click();
+ cy.get("[role=option]").first().click();
+}
diff --git a/cypress/e2e/projects/projects-shell.cy.js b/cypress/e2e/projects/projects-shell.cy.js
index 2676d9da..6e484d68 100644
--- a/cypress/e2e/projects/projects-shell.cy.js
+++ b/cypress/e2e/projects/projects-shell.cy.js
@@ -76,7 +76,17 @@ describe("Ara Projects route shell", function () {
cy.get("[data-testid=projects-shell]").should("exist");
});
- it("renders the per-project dashboard shell and its nav", function () {
+ // This assertion changed with #409. While the authz stub was in place,
+ // `canViewProject` could never return false, so /projects/ rendered
+ // the dashboard shell for any signed-in user — which is what this test used
+ // to check. The real lib (#408) is now wired in, so a project the user has no
+ // standing on is a 404, and a project id that does not exist is the same 404
+ // (deliberately indistinguishable, so Project existence does not leak).
+ //
+ // Rendering the dashboard shell therefore now needs a real, visible project,
+ // which is covered by the create-project happy path in create-project.cy.js
+ // rather than by a hardcoded id here.
+ it("404s on a project the user has no standing on", function () {
cy.login({
email: "dev@domna.homes",
name: "Internal User",
@@ -84,13 +94,21 @@ describe("Ara Projects route shell", function () {
sub: "cypress-internal",
});
- cy.visit("/projects/1");
+ cy.request({ url: "/projects/999999999", failOnStatusCode: false })
+ .its("status")
+ .should("eq", 404);
+ });
- cy.get("[data-testid=project-shell]").should("exist");
- cy.get("[data-testid=project-dashboard-heading]").should("be.visible");
-
- ["dashboard", "workOrders", "import", "settings"].forEach((item) => {
- cy.get(`[data-testid=projects-nav-${item}]`).should("be.visible");
+ it("404s on a malformed project id", function () {
+ cy.login({
+ email: "dev@domna.homes",
+ name: "Internal User",
+ onboarded: true,
+ sub: "cypress-internal",
});
+
+ cy.request({ url: "/projects/not-an-id", failOnStatusCode: false })
+ .its("status")
+ .should("eq", 404);
});
});
diff --git a/docs/adr/0019-uk-date-format-in-form-inputs.md b/docs/adr/0019-uk-date-format-in-form-inputs.md
new file mode 100644
index 00000000..13b3a2aa
--- /dev/null
+++ b/docs/adr/0019-uk-date-format-in-form-inputs.md
@@ -0,0 +1,102 @@
+# 19. Dates are entered and displayed as dd/mm/yyyy, not with ``
+
+Date: 2026-07-22
+
+## Status
+
+Accepted
+
+Numbering note: 0015–0017 are in flight on the reporting-redesign branch
+(PR #416), alongside 0010; 0019 follows ADR-0018 and avoids collision with all
+of them.
+
+## Context
+
+This is a UK product. Everything we *display* already knows that — some forty
+call sites pass `"en-GB"` to `toLocaleDateString` / `Intl.DateTimeFormat`, so a
+rendered date reads `3 September 2026` or `03/09/2026`.
+
+Form **inputs** did not follow. Every date field in the app was
+``, and the native control's display format is chosen by the
+**browser's** locale, not the document's. A developer or user whose Chrome is
+configured `en-US` sees `mm/dd/yyyy` in the same app that renders `dd/mm/yyyy`
+everywhere else.
+
+That inconsistency is not cosmetic. `03/09/2026` is a valid date under both
+readings — 3 September to a UK reader, 9 March to a US one. A user entering a
+project's start date has no way to tell which one the box took, and no
+validation can catch it, because both are dates. The failure mode is silently
+wrong data written months into a works programme, not an error message.
+
+The `lang` attribute does not fix this: Chromium and Firefox both take the date
+input's format from browser/OS locale settings and ignore document language.
+There is no attribute, CSS property or polyfill that makes the native control
+render day-first on demand.
+
+The options were:
+
+1. **Leave it.** Accept that the entry format varies per viewer.
+2. **Use the native control but display the format in the label** —
+ "Start date (dd/mm/yyyy)". Honest, but a lie on a US-locale browser, where
+ the box then contradicts its own label.
+3. **Replace the native control with a masked text input** that reads and
+ writes `dd/mm/yyyy`, keeping `YYYY-MM-DD` as the value, and supply our own
+ calendar to replace the native one it displaces.
+
+## Decision
+
+**`dd/mm/yyyy` is the date format for the whole app, in inputs as well as
+output.** A date shown to or typed by a person is day-first, everywhere, for
+every user, regardless of their browser's locale.
+
+**Date form fields use `DateInput` (`src/app/components/DateInput.tsx`), not
+``.** It is a text input that masks to `dd/mm/yyyy` as the
+user types, with a calendar picker in a popover, and whose `value`/`onChange`
+remain `YYYY-MM-DD`. The parsing and formatting are pure functions in
+`src/utils/dates.ts` (`formatUkDate`, `parseUkDate`, `formatUkDateWhileTyping`,
+`isoToLocalDate`, `localDateToIso`), unit-tested independently of React.
+
+**`YYYY-MM-DD` remains the only format that crosses a boundary** — form state,
+request bodies, Drizzle `date` columns. The display format stops at the input's
+edge, so schemas, route handlers and the database are untouched by this
+decision, and dates keep sorting lexicographically with no timezone in play.
+
+**Output keeps using `"en-GB"`** with `toLocaleDateString` / `Intl`. This ADR
+does not introduce a second way to render a date; it makes entry agree with
+what rendering already did.
+
+## Consequences
+
+- **The picker is replaced, not lost.** Dropping the native control also drops
+ its calendar, which was judged an acceptable cost — a picker is a
+ convenience, an ambiguous entry format is a correctness problem — but the
+ loss was immediately felt in use, so the calendar was rebuilt *behind*
+ `DateInput` as this ADR anticipated: a popover writing the same ISO value,
+ rather than a revert to the native control. It uses `react-day-picker` via
+ `shadcn_components/ui/calendar.tsx`, defaulting to the `enGB` locale, whose
+ `weekStartsOn: 1` puts Monday first. Typing and picking are equal routes to
+ the same value.
+- **Two new dependencies**: `react-day-picker` and `date-fns` (the latter was
+ already present transitively; it is now direct). The alternative was a
+ hand-rolled month grid with no dependency, rejected because keyboard
+ navigation and ARIA on a calendar are easy to get subtly wrong and not worth
+ owning.
+- **The `Date` boundary is confined to two functions.** A calendar works in
+ `Date`s, so `isoToLocalDate` / `localDateToIso` bridge to them — building and
+ reading *local* parts, never `new Date(iso)` or `toISOString()`, both of
+ which shift the day across midnight for a non-zero UTC offset. Everything
+ else in `src/utils/dates.ts` stays string-only. The round trip is tested
+ across a full month.
+- **Caret handling is a masked input's usual compromise.** Typing at the end of
+ the field, which is the overwhelming case, behaves normally; editing in the
+ middle can move the caret to the end. Judged not worth a masking dependency.
+- **A finished-but-impossible date is passed to the schema as typed** rather
+ than reported as empty, so `31/02/2026` produces "Enter a valid date"
+ instead of silently reading as an unfilled optional field. Unfinished input
+ reports empty, so no error appears mid-keystroke.
+- **Migration is incremental.** `DateInput` is used by the create-project modal
+ (#409). Five other `` fields remain, in
+ `PibiSection.tsx`, `PibiDatesEditor.tsx` and `PropertyFilters.tsx`; they are
+ wrong under this ADR and should be converted when next touched.
+- **New date fields have one right answer**, recorded in CLAUDE.md so it is
+ found before the wrong one is written.
diff --git a/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md b/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md
new file mode 100644
index 00000000..502275df
--- /dev/null
+++ b/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md
@@ -0,0 +1,104 @@
+# 19. Work-order import is the setup-gated final wizard step, and carries contractor per row
+
+Date: 2026-07-22
+
+## Status
+
+Accepted
+
+Numbering note: 0015–0017 are in flight on the reporting-redesign branch, and
+0010 alongside them (see ADR-0018's note). 0019 follows 0018 and avoids all of
+them.
+
+## Context
+
+The Ara Projects "second import" (issue #414) matches a client-supplied
+spreadsheet to Properties already in the DB and declares which Workstreams each
+Property gets, as one row per (Property, Workstream). Committing those rows as
+Work orders is #417.
+
+Two questions came up once the schema (PR #404) and the setup wizard shape
+(wireframes 02–07) were concrete:
+
+1. **When is a project allowed to import?** A `work_order` row is
+ `(property, project_workstream_stage, project_workstream_contractor,
+ reference, forecast_end)` — all NOT NULL except `forecast_end`. So a Work
+ order cannot exist for a workstream that has no stage ladder and no assigned
+ contractor. Importing against a half-configured project would fail row by
+ row at commit with nothing the user could act on up front.
+
+2. **Where does each row's contractor come from?** The original #417 sketch
+ derived the contractor from the workstream: each `project_workstream` was
+ assumed to have a single contractor assignment, so the importer would look
+ it up and every Work order for that workstream went to that one org. But
+ `project_workstream_contractor` is a **collection** — a workstream can have
+ more than one contractor assigned (CONTEXT.md, "Contractor"), and real
+ programmes split one workstream's properties across several contractors by
+ area or batch. A single-assignee lookup cannot express that.
+
+## Decision
+
+**Import is the final, setup-gated step of the setup wizard.**
+
+- The wizard's earlier steps select workstreams, configure each workstream's
+ stage ladder, and assign contractors. Import is the last step; #413 routes
+ the wizard into this same `/projects/[projectId]/import` screen. It also
+ stays reachable standalone from its own nav button.
+- The screen is **gated on setup completeness** regardless of entry point.
+ Rule: every `project_workstream` on the project must have **≥1
+ `project_workstream_stage` AND ≥1 `project_workstream_contractor`**, and the
+ project must have at least one workstream. Until then the drop zone is
+ replaced by a "finish setup" state naming the incomplete workstreams and
+ linking back to configuration — not a 404 and not a silent empty zone.
+- Evidence requirements (#412) are advisory and are **not** part of the gate.
+- The gate is computed by `computeImportReadiness` (pure) /
+ `loadImportReadiness` (one read-only query) in
+ `src/lib/projects/import/readiness.ts`, returning per-workstream detail so
+ both this page and #413 can render *what* is missing.
+
+**Contractor is supplied per import row.**
+
+- The template gains a required `Contractor` column. Each row names the
+ contractor that delivers that (Property, Workstream), and it must match a
+ contractor **already assigned** to that workstream via
+ `project_workstream_contractor`. Validation (#417) rejects a contractor that
+ is not an existing assignment.
+- **Contractor creation is out of scope for import.** Bringing a new company
+ into Ara and assigning it to a workstream is owned by the pre-existing
+ "invite company to Ara" flow; import only references assignments that flow
+ has already made. This is why the gate requires a contractor per workstream
+ before import opens — the assignments must exist first.
+
+**Stage is optional per row.**
+
+- The template gains an optional `Stage` column. A blank stage falls back at
+ commit to the workstream's first stage (`project_workstream_stage` ordered by
+ `order`, ascending — the ladder's start; see CONTEXT.md, "Stage"). A named
+ stage must match one on that workstream's ladder.
+
+The raw parser (`parse.ts`) stays header-agnostic: it does not enforce the
+required/optional distinction or that names resolve. Required-ness is the
+mapping step's job (#415) and value resolution is the commit step's (#417).
+
+## Consequences
+
+- **Trade-off vs the replaced #417 design.** Deriving the contractor from the
+ workstream's single assignee was simpler — one fewer required column, and no
+ per-row contractor to validate. We give that up because it silently assumed
+ one contractor per workstream, which the schema does not guarantee and real
+ programmes violate. Carrying the contractor per row costs the client a column
+ they must fill and a validation failure mode ("contractor X isn't assigned to
+ workstream Y"), but it is the only shape that supports a workstream split
+ across contractors, and it keeps the assignment set — not the spreadsheet —
+ as the source of truth for who may receive work.
+- The setup gate means a project cannot reach the drop zone mid-configuration.
+ That is the intended safety property (no un-committable imports), but it does
+ make import unreachable until contractors are assigned — acceptable because
+ assignment is a prerequisite for issuing any work order at all.
+- `readiness.ts` has two consumers (this page and #413), so its interface is
+ kept deliberately stable and detail-rich.
+- Not yet decided here: how a row's named contractor disambiguates when the
+ same org is assigned to a workstream more than once, and the exact
+ stage-name matching rule. Both are #417's to settle at commit; this ADR fixes
+ only that the contractor is per-row-and-pre-assigned and the stage is
+ optional-with-first-stage-fallback.
diff --git a/docs/adr/0020-import-session-and-mapping-are-client-held.md b/docs/adr/0020-import-session-and-mapping-are-client-held.md
new file mode 100644
index 00000000..46224315
--- /dev/null
+++ b/docs/adr/0020-import-session-and-mapping-are-client-held.md
@@ -0,0 +1,91 @@
+# 20. The work-order import session — and the mapping persisted alongside it — is client-held
+
+Date: 2026-07-23
+
+## Status
+
+Accepted
+
+Numbering note: two ADRs carry 0019 (the UK date format one, and the
+work-order-import one from #414's branch). 0020 follows both.
+
+## Context
+
+Issue #415 (import step 2 of 3) asks for two mapping layers over the parsed
+file — column mapping and value mapping — and for the result to be
+"persist[ed] alongside the import session".
+
+The import journey has no session row. #414 decided that the parse route holds
+nothing: it reads the uploaded file, returns **every** row to the browser, and
+writes nothing. The mapping, validation and commit steps all read that array
+from the import screen's React state, and commit (#417) is the first and only
+write. There is no `import_session` table, no staged file in S3, and no
+`work_order` row until the user commits.
+
+So "alongside the import session" had to be given a meaning. Three options:
+
+1. **A database session.** Add `import_session` (file identity, status, a JSONB
+ mapping), write it when the file is parsed, PATCH it as the user maps.
+2. **Browser storage.** Keep the session in React state and mirror it to
+ `sessionStorage` so a refresh can restore it.
+3. **A client-held session object.** Define `ImportSession` — file identity plus
+ both mapping layers — hold it in the import screen's state next to the parsed
+ rows, and hand it to commit.
+
+## Decision
+
+**Option 3.** `src/lib/projects/import/session.ts` defines `ImportSession`; the
+import screen owns one, the mapping component edits it, and #417 will POST it
+with the rows at commit.
+
+The shape is deliberately **JSON-clean end to end** — plain objects, string ids
+(every `bigserial` is stringified at `mappingTargets`), no `bigint`, no `Date`,
+no class instances. It can be POSTed verbatim and stored in a JSONB column
+unchanged, which is how `bulk_address_uploads.column_mapping` already holds the
+older BulkUpload journey's equivalent.
+
+`sessionMatchesFile` guards resumption: a mapping is a set of **column
+indices**, so a session is meaningless against a file whose headings differ, and
+restoring one would silently map the wrong columns.
+
+### Why not the database session
+
+Persisting the mapping without the rows buys the user nothing. The rows are
+client-held, so a refresh loses them regardless; the user must re-upload, and a
+restored mapping cannot be applied to a file that is no longer in hand. A
+session row would therefore be durable state that no journey can currently
+resume from — and it would cost a migration, a status lifecycle, a PATCH
+endpoint and an expiry story to earn it.
+
+There is also a constraint worth recording plainly: this branch works against a
+shared production database and was scoped to write nothing — no migrations. That
+ruled option 1 out for *this* ticket, but it is not the reason it was rejected;
+the reason is that the rows and the mapping have to be durable together or
+neither is useful.
+
+### Why not `sessionStorage`
+
+It survives a refresh, but only for the mapping — the rows still do not, so the
+restored mapping has no file to apply to. It also has to be read at mount, which
+in a server-rendered client component means either a hydration mismatch or an
+effect, and this codebase avoids `useEffect` by convention.
+
+## Consequences
+
+- A page refresh loses the import in progress, and the user re-uploads. This is
+ unchanged from #414, and the mapping does not make it worse.
+- Stepping back to the drop zone and forward again does **not** lose the
+ mapping: the session lives in `ImportUpload`, above the mapping component, so
+ the mapping component may unmount freely. This is why every edit reports the
+ whole session upward rather than only living in the table's own state.
+- No server-side record exists of who mapped what, or of a mapping that was
+ never committed. Nothing in v1 asks for one; if audit becomes a requirement,
+ it lands with the session row, not before.
+- Two mapping layers on 20,000 rows are re-derived on every edit rather than
+ cached. That is affordable (a few passes over three cells per row) and is what
+ keeps the tables from disagreeing with the mapping the user is editing.
+- **What would change the answer:** moving the file server-side. If the parsed
+ rows are ever staged (S3, or a rows table) — which is the natural fix for
+ files too large to hold in the browser, and the shape the BulkUpload journey
+ already uses — then a session row becomes worth its cost, and `ImportSession`
+ is already the payload it would store.
diff --git a/docs/adr/0021-contractor-permissions-are-per-assignment-shown-per-contractor.md b/docs/adr/0021-contractor-permissions-are-per-assignment-shown-per-contractor.md
new file mode 100644
index 00000000..7ae631e5
--- /dev/null
+++ b/docs/adr/0021-contractor-permissions-are-per-assignment-shown-per-contractor.md
@@ -0,0 +1,100 @@
+# 21. Contractor permissions are per-assignment, shown per-contractor; a removal never cascades through work orders
+
+Date: 2026-07-23
+
+## Status
+
+**Decision 1 superseded by [ADR-0022](./0022-contractor-permissions-are-edited-and-shown-per-workstream.md)**
+(2026-07-23): permissions are now edited per workstream in a grid and shown per
+workstream in an expandable sub-row, so no per-contractor `mixed` state exists
+any more. Decisions 2 (removal never cascades through work orders) and 3
+(Continue leads to the import) stand, and the Context below still explains why
+the unit of a permission is the assignment.
+
+Numbering note: two ADRs carry 0019 (the UK date format one, and the
+work-order-import one from #414's branch). 0021 follows 0020.
+
+## Context
+
+Issue #413 asks for a contractors-and-permissions screen matching the wireframe
+`06-contractors-permissions.html`: a table whose rows are **contractor
+organisations**, each showing the workstreams it delivers plus an "Update
+stages" and an "Upload docs" switch.
+
+The schema underneath disagrees about the unit. `project_workstream_contractor`
+is one row per **(Project Workstream, organisation)** pair, and both permission
+flags live on that row — deliberately, because the authz guards read them
+per work order: `canUpdateStage` checks the flag on the assignment the work
+order was issued under, not on the organisation (`@/lib/projects/authz`). Apex
+may legitimately update stages on Windows and not on Roofs.
+
+So a table row is *n* rows of the table it renders, and one switch is *n*
+booleans. Three things had to be decided.
+
+**1. What a switch shows when the assignments disagree.** Options: show the
+majority; show `true` if any assignment has it; show a third state.
+
+**2. What removing a contractor means when work has been issued.** `work_order`
+references its assignment by a **NOT NULL** FK
+(`work_order.project_workstream_contractor_id`), and that FK is how a work order
+knows which contractor holds it. Deleting an assignment with work orders against
+it therefore either fails on the constraint, or requires deleting/reassigning
+the work orders first. The issue is explicit that orders keep their FK and that
+we "surface this rather than cascading".
+
+**3. Where the wizard goes next.** #413 was written as the last step before the
+dashboard; the 2026-07-22 reframe made it the last *configuration* step, with
+the work-order import (#414) as the wizard's terminal step.
+
+## Decision
+
+**1. Three states: `on`, `off`, `mixed`.** `permissionState` in
+`src/app/api/projects/[projectId]/contractors/assignments.ts` reads a
+contractor's assignments and returns `"mixed"` when they disagree. The switch
+renders it amber and labelled, and clicking a mixed switch resolves **upwards**:
+the write grants the permission on every workstream that contractor delivers.
+Rounding a disagreement to on or off silently would either widen a permission
+nobody granted or revoke one somebody did.
+
+The write path is the same shape: PATCH takes the whole assignment set for one
+contractor and applies one pair of flags across it. Divergent flags can still be
+written — by another surface, or by a future per-workstream editor — and the
+table will keep reporting them honestly rather than pretending they cannot
+exist.
+
+**2. A removal with work orders is refused, not cascaded and not confirmed
+away.** `decideRemoval` returns `blocked` with the count and the workstreams
+named; `?confirm=true` does not override it, because there is nothing for the
+user to consent to — the delete is impossible while the FK stands. This matches
+the identical decision #410 made for deselecting a workstream, so "work orders
+pin their configuration in place" is one rule across the setup screens, not two.
+
+A removal that merely leaves a workstream with **no** contractor is different:
+it is legal, it is sometimes what the user wants, and it is precisely what the
+import gate (ADR-0019) then blocks on. That warns — `needs-confirmation`, one
+`?confirm=true` away — rather than being refused.
+
+**3. Continue leads to the import.** The screen loads `loadImportReadiness`
+(#414's shared helper) rather than deciding readiness itself, renders the
+outstanding gaps when the project is not ready, and links Continue to
+`/projects/[projectId]/import` regardless. The import screen renders the same
+readiness detail from the same helper, so an incomplete project meets an
+explanation there rather than a dead end here — and a disabled Continue that
+cannot say why is avoided.
+
+## Consequences
+
+- The permissions table is a **projection**, not a mirror, of
+ `project_workstream_contractor`. Anything that needs the per-assignment truth
+ (the authz guards, work-order issue) must read the rows, never the projection.
+- Granting a permission to a contractor grants it across every workstream that
+ contractor delivers on the project. Per-workstream permissions are still
+ expressible in the schema and are still enforced; there is simply no UI that
+ produces them in v1. A future per-workstream editor is additive — the `mixed`
+ state already exists to describe what it would create.
+- A contractor cannot be taken off a project once work has been issued to it.
+ Withdrawing one mid-programme is a work-order operation first, and no screen
+ in this feature performs it.
+- The setup wizard now has six steps, of which this is the fifth. The step
+ counter on the earlier screens (#410 renders "Step 2 of 5") predates the
+ reframe and should be reconciled with the same count.
diff --git a/docs/adr/0021-stage-ladder-seeding-and-the-status-column.md b/docs/adr/0021-stage-ladder-seeding-and-the-status-column.md
new file mode 100644
index 00000000..1a24e594
--- /dev/null
+++ b/docs/adr/0021-stage-ladder-seeding-and-the-status-column.md
@@ -0,0 +1,146 @@
+# 21. Stage ladders: seeded on entering setup, keyed on `workstream.id`, and `status` written once as a placeholder
+
+Date: 2026-07-23
+
+## Status
+
+Accepted
+
+## Context
+
+Issue #411 builds the stage-configuration setup screen. Each Project
+Workstream owns an ordered ladder (`project_workstream_stage`, positioned by
+`order`), and the ladder is the **only** lifecycle mechanism in v1: a Work
+order's position is its stage FK, and the terminal stage is the one with the
+highest `order` (CONTEXT.md, **Stage**). `work_order.status`,
+`work_order.priority` and `project_workstream.current_stage_id` are deliberately
+untouched, deferred to #426.
+
+Four things about that had to be decided.
+
+### 1. Where the default ladder gets seeded
+
+Every selected workstream should start on the v1 default ladder — Ordered → In
+progress → Completed → Charged → Closed — and be seeded **exactly once**, per
+the issue's acceptance criteria. A workstream with no stages is what the import
+readiness gate (#414) reports as incomplete, so the seed is what unlocks half
+of import for that workstream.
+
+The candidates were: seed at workstream selection (#410's POST), seed on the
+first GET of the stages API, or seed when the setup screen is entered.
+
+### 2. Which id the stage routes are keyed on
+
+Issue #411 specifies the CRUD routes as
+`/api/projects/[projectId]/workstreams/[projectWorkstreamId]/stages`. That
+directory cannot exist: #410 already owns
+`/api/projects/[projectId]/workstreams/[workstreamId]` for the deselect
+endpoint, and Next.js refuses two different slug *names* for the same dynamic
+path segment — "You cannot use different slug names for the same dynamic path
+(`projectWorkstreamId` !== `workstreamId`)". It is a hard failure at boot and at
+build, not a warning, and it takes the whole app down rather than just these
+routes.
+
+So the segment is shared, and the only question left is what the id in it means.
+
+### 3. `project_workstream_stage.status`
+
+The column is `text NOT NULL` with no database default (migration 0273). Its
+value set is still TBC — the schema's own comment says so — and #411's
+acceptance criteria say no writes to `status` anywhere. Those two facts
+contradict: a stage row cannot be inserted without *some* value in a `NOT NULL`
+column, so "seed the default ladder" and "never write `status`" cannot both be
+taken literally.
+
+### 4. The optional per-stage dates
+
+`project_workstream_stage` carries nullable `start_date` and `due_date`
+columns, and the issue asked for them to be exposed as optional inputs. On
+review of the built screen they were rejected: a stage is a rung of a workflow,
+not a piece of scheduled work, so there is no date it can meaningfully hold.
+Delivery dates belong to a Work order (`work_order.forecast_end` already exists).
+
+## Decision
+
+**The stage routes are keyed on `workstream.id`**, the catalogue row — the same
+id the shared `[workstreamId]` segment already means for #410's deselect. The
+URL shape the issue asked for is unchanged (`/workstreams/9/stages`); only the
+id space is. `findLadder(projectId, workstreamId)` resolves it to the
+`project_workstream.id` that every write function takes, exactly once per
+request, at the edge.
+
+**Seeding happens in the setup page's server component**, before it reads, and
+only for a caller who may manage the project. `seedDefaultLadders(projectId)`
+selects the project's `project_workstream` rows `FOR UPDATE`, re-reads their
+stage counts under that lock, and inserts a default ladder only for those with
+none.
+
+**Inserts write `status = "active"` as a placeholder, once, and no update path
+ever touches the column again.** The constant lives in one place
+(`PLACEHOLDER_STAGE_STATUS`) and is not exposed in the UI, not read by any
+query, and not part of any API contract. It matches what
+`seed-projects-dashboard.ts` already writes, so seeded and user-created rows
+agree.
+
+**`start_date` and `due_date` are not exposed and never written.** They are
+absent from the request schemas, the payload and the UI, so a ladder stage
+leaves them NULL for every row this screen creates. The columns stay on the
+table for now; whether they are dropped is a schema question deferred to a
+later issue, and nothing in #411 depends on the answer.
+
+`project_workstream.current_stage_id` and `work_order.status` are genuinely
+never written by #411 — the AC holds for both columns as stated. A stage that
+`current_stage_id` points at cannot even be deleted from this screen.
+
+## Consequences
+
+- **One URL segment means one thing.** `/workstreams/9` and
+ `/workstreams/9/stages` name the same workstream. The alternative — keeping
+ `project_workstream.id` under a segment that means `workstream.id` one level
+ up — would have type-checked, passed its tests, and quietly addressed a
+ different workstream whenever the two ids diverged. Nothing in the URL would
+ have shown it.
+- **The `project_workstream.id` never appears in a URL**, so it stays an
+ internal identifier. It is still what the readiness helper (#414) and the hub
+ (#444) pass around in-process, and it is still what the ladder payload carries
+ for the client that needs it.
+- The resolution costs one extra join per request, on a query the handler was
+ making anyway to prove the workstream belongs to the project.
+- **The seed is idempotent under concurrency, not merely on a revisit.** Two
+ simultaneous first visits cannot both find "no stages" and both insert: the
+ second transaction blocks on the row lock, then re-reads and finds the ladder.
+ "Seeded exactly once per workstream" is therefore true rather than likely.
+- **A page render performs a write.** Unusual, and accepted deliberately: it is
+ the only trigger that satisfies "on entering this step" while still having the
+ first paint show the real ladder rather than an empty one that fills in
+ afterwards. It is idempotent, so a prefetch or a double render costs nothing
+ but a locked read. Seeding on GET was rejected for making a read endpoint
+ mutate; seeding in #410's POST was rejected because #411 must not edit that
+ route, and because a workstream selected before this decision existed would
+ never be seeded.
+- **A read-only visitor sees empty ladders on a project nobody has configured
+ yet**, because seeding is skipped for them. That is honest — the workstream
+ genuinely has no stages, and the readiness gate says so — and it keeps a
+ viewer from creating rows.
+- **The dates decision supersedes an acceptance criterion of #411**, which
+ asked for the date inputs. It was taken after seeing them on the screen, and
+ the issue should be amended rather than the code quietly diverging from it.
+- **A stage-level date has nowhere to come back to cheaply**: reinstating the
+ inputs means a schema keep-or-drop decision first. That is the intended
+ order — the columns are unused rather than half-used in the meantime.
+- **`status` carries no meaning and must not acquire one by accident.** Anything
+ reading it today would be reading a placeholder. When #426 agrees the value
+ set, that issue owns both the enum conversion and the backfill of rows written
+ here.
+- If the column later gains a database default, the placeholder write can be
+ dropped with no other change: nothing depends on the value.
+
+## References
+
+- Issue #411 — Ara Projects: setup wizard 3/5, stage configuration
+- Issue #410 — workstream selection, which owns the `[workstreamId]` segment
+- CONTEXT.md, **Stage** and **Work order**
+- [ADR-0019](./0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md)
+ — import is setup-gated, which is what makes an unseeded ladder visible to the
+ user as a blocker
+- Migration `0273_normal_bloodstrike.sql` — the `project_workstream_stage` DDL
diff --git a/docs/adr/0022-contractor-permissions-are-edited-and-shown-per-workstream.md b/docs/adr/0022-contractor-permissions-are-edited-and-shown-per-workstream.md
new file mode 100644
index 00000000..aeb48ee9
--- /dev/null
+++ b/docs/adr/0022-contractor-permissions-are-edited-and-shown-per-workstream.md
@@ -0,0 +1,88 @@
+# 22. Contractor permissions are edited and shown per workstream
+
+Date: 2026-07-23
+
+## Status
+
+Accepted. Supersedes decision 1 of
+[ADR-0021](./0021-contractor-permissions-are-per-assignment-shown-per-contractor.md);
+its other two decisions stand.
+
+## Context
+
+ADR-0021 kept the wireframe's shape: one table row per contractor organisation,
+with an "Update stages" and an "Upload docs" **switch** on that row. Because
+`project_workstream_contractor` stores those flags per (workstream,
+organisation) pair, one switch stood for *n* booleans, and a contractor whose
+assignments disagreed was reported as `mixed`.
+
+Building it exposed the flaw. A switch is a control that asserts one state and
+sets one state; it cannot honestly represent *n*, so:
+
+- the `mixed` state needed a chip, a tooltip and a bespoke accessible name
+ before it was even comprehensible — a lot of explanation for a control that
+ was still lying about its own shape;
+- the only write it could express was "apply one pair of flags to everything",
+ so the UI could **read** a per-workstream split but never **author** one. The
+ schema's central capability was unreachable from the only screen that
+ configures it, and ADR-0021 had to note that no v1 surface produced divergent
+ flags;
+- clicking a `mixed` switch destroyed information the user might not have
+ looked at, and "resolve upwards" was a rule invented to make an unsuitable
+ control decidable.
+
+Meanwhile the actual question — *may this contractor move Windows along, and
+may it upload evidence on Roofs?* — is a grid: workstreams down, permissions
+across. And on the table, permissions are detail: who delivers what is the
+scanning question, and every row spending two columns on flags nobody is
+reading crowds it out.
+
+## Decision
+
+**Permissions are edited per workstream, in a grid.** The add/edit dialog shows
+one row per workstream the *project* covers — not merely the ones this
+contractor holds — with a tick to assign it and a tick per permission.
+Unassigned rows stay visible and greyed, their permission boxes disabled: the
+shape of the project is part of the decision, and a workstream nobody covers is
+worth seeing at the moment someone is being assigned. Un-ticking a workstream
+clears its permissions, so re-ticking it never silently restores a grant. Each
+column header carries an all/none shortcut, because ticking twenty boxes by
+hand is how mistakes get made.
+
+**The main table hides permissions until asked.** A row shows the contractor,
+the workstreams it delivers, and its Actions. Clicking the row — or its
+disclosure button, which is what carries `aria-expanded`/`aria-controls` for
+keyboard and screen-reader users — opens a sub-row listing each assignment with
+its two permissions, read-only. Several rows may be open at once; comparing two
+contractors is a normal thing to want. Actions stay per contractor, since Edit
+and Remove act on the whole assignment set.
+
+**The wire shape follows.** POST and PATCH take `assignments: [{
+projectWorkstreamId, updateStagesPermission, uploadDocumentsPermission }]`
+instead of one id list plus one pair of flags. A workstream may appear only
+once per request — two entries for the same workstream are two answers to one
+question, and picking one silently is worse than a 400. `ContractorGroup` no
+longer carries a per-contractor summary of either permission, and
+`permissionState` is gone: nothing needs the flags reduced to one answer, and
+reducing them is precisely how a contractor allowed to update stages on one
+workstream comes to look allowed everywhere.
+
+## Consequences
+
+- The screen can now express what the schema always stored, and what the authz
+ guards have always enforced per assignment (`canUpdateStage` reads the flag on
+ the work order's own assignment). UI, storage and enforcement finally agree on
+ the unit.
+- Divergent permissions are no longer an anomaly to be explained but the normal
+ output of an ordinary edit. The `mixed` vocabulary, its chip and its tooltip
+ are deleted rather than reworded.
+- Setting one permission across many workstreams costs one click per box, or
+ one on the column header. That is more work than the single switch it
+ replaces — accepted deliberately: the switch was cheap because it was coarse.
+- The table's first read is now "who delivers what", with permissions one click
+ away. A future need to scan permissions across contractors (an audit view)
+ would want a different surface, not these columns back.
+- Editing permissions on one workstream sends the contractor's whole assignment
+ set. The removal rules still run over exactly the workstreams a request drops,
+ so an edit that also un-ticks a workstream with work orders is still refused
+ (ADR-0021, decision 2).
diff --git a/docs/adr/0023-installation-in-progress-gated-on-design-document.md b/docs/adr/0023-installation-in-progress-gated-on-design-document.md
new file mode 100644
index 00000000..6d76712c
--- /dev/null
+++ b/docs/adr/0023-installation-in-progress-gated-on-design-document.md
@@ -0,0 +1,96 @@
+# 23. Installation in Progress is gated on the Retrofit Design Document, not the HubSpot designStatus flag
+
+Date: 2026-07-24
+
+## Status
+
+Accepted
+
+## Context
+
+On the Live projects screen (`your-projects/live`), a Property's delivery stage
+is derived from HubSpot deal fields by the stage classifier
+(`resolveDisplayStage` → `classifyDeals` → `computeLiveTrackerData` in
+`transforms.ts`). After coordination is complete, the classifier decided
+*Design in Progress* vs *Installation in Progress* from the self-reported
+`designStatus` text field: `designStatus === "UPLOADED"` meant "design is done,
+installation can happen".
+
+That flag is set in the CRM independently of the actual design work. Observed
+values include `"UPLOADED"`, `"uploaded"` and `"IN PROGRESS"`, and it is
+routinely set before — or without — the **Retrofit Design Document**
+(`retrofit_design_doc`) ever being uploaded. The CRM flag and the document store
+are two independent sources of truth that had silently diverged, so large
+numbers of Properties with no design in place showed as *Installation in
+Progress*. Managers could not trust the pipeline counts, the funnel, or the
+stage badges, and Properties genuinely waiting on design were hidden inside a
+later stage.
+
+The domain owner confirmed there is only ever one design document per Property —
+the Retrofit Design Document — and its presence is the correct trigger for
+*Installation in Progress*.
+
+## Decision
+
+**Design-document presence, not the `designStatus` flag, drives the
+Design-vs-Installation boundary.**
+
+- The classifier stops reading `designStatus`. `designStatus` remains on the
+ deal record and may still be displayed (and still feeds the separate design
+ throughput trends chart), but it no longer influences the stage.
+- Whether a `retrofit_design_doc` exists for a deal is passed **into** the pure
+ classifier as an explicit input — `resolveDisplayStage(deal, hasDesignDoc)`,
+ `classifyDeals(deals, designDocDealIds)`,
+ `computeLiveTrackerData(deals, designDocDealIds)`. The classification
+ functions perform no I/O; presence is derived by
+ `deriveDesignDocDealIds(docsByDealId)` from the documents already fetched
+ (direct deal-id match and UPRN fallback), using `DESIGN_DOC_TYPES` as the
+ definition of a design document. `page.tsx` and `[dealId]/page.tsx` now fetch
+ documents ahead of classification so the same fetch feeds both the presence
+ set and the document-status map.
+
+- **After coordination, downstream evidence wins; document presence only
+ decides the Design/Installation boundary.** For a coordination-complete deal
+ (a `(V1|V2|V3) IOE/MTP COMPLETE` marker, not an RA issue) the stage resolves
+ in this priority:
+
+ ```
+ full lodgement date present → Project Complete
+ measures lodgement date present → At Post Survey
+ lodgement status present → At Lodgement
+ measures installed OR installer handover→ Installation Complete
+ Retrofit Design Document present → Installation in Progress
+ otherwise → Design in Progress
+ ```
+
+ Gating the whole post-coordination block on document presence would drag a
+ genuinely installed or lodged Property back to *Design in Progress* when its
+ design document is merely missing from the store. Document presence is the
+ weakest signal in the ladder, not a gate on the rest.
+
+- The document-status map's install bucket is corrected: a `retrofit_design_doc`
+ is no longer mis-bucketed as an install document, so a Property holding only a
+ design document is not reported as having install documents.
+
+Coordination and earlier stages are untouched — the RA-issue → Queries path,
+the removed-Property short-circuits, the dealstage-id → raw-stage mapping and
+the Coordination in Progress path all behave exactly as before.
+
+## Consequences
+
+- A Property shows *Installation in Progress* only once its Retrofit Design
+ Document exists; one whose coordination is complete but whose document is
+ missing stays at *Design in Progress*, where the outstanding design work is
+ visible. Funnel and stage-progress counts (and the "All Projects" roll-up)
+ follow the corrected per-deal classification, as does the Properties-tab stage
+ filter.
+- A prematurely-set or stale `designStatus` flag can no longer push a Property
+ into *Installation in Progress*.
+- `DESIGN_DOC_TYPES` (previously defined but unused) becomes load-bearing as the
+ single definition of what counts as a design document.
+- Out of scope, deliberately: correcting the underlying `designStatus` data or
+ alerting on CRM-vs-document mismatch; surfacing a design-completeness badge on
+ the Documents tab; multi-document or per-measure design requirements (there is
+ only ever one design document per Property). The Live projects feature itself
+ remains superseded-in-principle by Ara Projects per ADR-0018; this fix keeps
+ it correct in the meantime.
diff --git a/package-lock.json b/package-lock.json
index 7bc83da5..12c9f9b8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,6 +46,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
+ "date-fns": "^4.4.0",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
@@ -61,6 +62,7 @@
"postcss": "^8.5.6",
"react": "18.3.1",
"react-confetti": "^6.4.0",
+ "react-day-picker": "^10.0.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.2",
"recharts": "^3.8.1",
@@ -1250,6 +1252,12 @@
"ms": "^2.1.1"
}
},
+ "node_modules/@date-fns/tz": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
+ "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
+ "license": "MIT"
+ },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -6036,12 +6044,36 @@
"node": ">=6"
}
},
+ "node_modules/@tremor/react/node_modules/date-fns": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
+ "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/@tremor/react/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
+ "node_modules/@tremor/react/node_modules/react-day-picker": {
+ "version": "8.10.2",
+ "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz",
+ "integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/gpbl"
+ },
+ "peerDependencies": {
+ "date-fns": "^2.28.0 || ^3.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@tremor/react/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -8528,9 +8560,9 @@
}
},
"node_modules/date-fns": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
- "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
@@ -13283,17 +13315,29 @@
}
},
"node_modules/react-day-picker": {
- "version": "8.10.1",
- "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
- "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
+ "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
"license": "MIT",
+ "dependencies": {
+ "@date-fns/tz": "^1.4.1",
+ "date-fns": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
- "date-fns": "^2.28.0 || ^3.0.0",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ "@types/react": ">=16.8.0",
+ "react": ">=16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
"node_modules/react-dom": {
diff --git a/package.json b/package.json
index 92fda474..5cee2c0b 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
+ "date-fns": "^4.4.0",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
@@ -73,6 +74,7 @@
"postcss": "^8.5.6",
"react": "18.3.1",
"react-confetti": "^6.4.0",
+ "react-day-picker": "^10.0.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.2",
"recharts": "^3.8.1",
diff --git a/src/app/api/projects/[projectId]/contractors/[organisationId]/route.test.ts b/src/app/api/projects/[projectId]/contractors/[organisationId]/route.test.ts
new file mode 100644
index 00000000..363b0d74
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/[organisationId]/route.test.ts
@@ -0,0 +1,397 @@
+/**
+ * PATCH / DELETE `/api/projects/[projectId]/contractors/[organisationId]`
+ * (issue #413).
+ *
+ * The database is mocked at the module boundary (`../queries` and the authz
+ * repository); no connection is opened. Both the authorization decisions
+ * (`@/lib/projects/authz`) and the removal rules (`../assignments`) are the
+ * real ones — only the facts they read are faked.
+ */
+import { describe, expect, it, beforeEach, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const {
+ mockGetServerSession,
+ mockLoadAuthzUser,
+ mockLoadProjectAuthzFacts,
+ mockFindContractorAssignments,
+ mockListProjectWorkstreams,
+ mockCountOtherContractors,
+ mockWriteContractorAssignments,
+ mockDeleteAssignments,
+} = vi.hoisted(() => ({
+ mockGetServerSession: vi.fn(),
+ mockLoadAuthzUser: vi.fn(),
+ mockLoadProjectAuthzFacts: vi.fn(),
+ mockFindContractorAssignments: vi.fn(),
+ mockListProjectWorkstreams: vi.fn(),
+ mockCountOtherContractors: vi.fn(),
+ mockWriteContractorAssignments: vi.fn(),
+ mockDeleteAssignments: 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", () => ({
+ findContractorAssignments: mockFindContractorAssignments,
+ listProjectWorkstreams: mockListProjectWorkstreams,
+ countOtherContractors: mockCountOtherContractors,
+ writeContractorAssignments: mockWriteContractorAssignments,
+ deleteAssignments: mockDeleteAssignments,
+}));
+
+import { DELETE, PATCH } 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 APEX = "44444444-4444-4444-4444-444444444444";
+
+const WORKSTREAMS = [
+ { projectWorkstreamId: "10", name: "Windows", contractorCount: 1 },
+ { projectWorkstreamId: "11", name: "Roofs", contractorCount: 1 },
+];
+
+/** Apex delivers Windows and Roofs, with no work orders against either. */
+const CURRENT = [
+ {
+ id: "100",
+ projectWorkstreamId: "10",
+ workstreamName: "Windows",
+ updateStagesPermission: true,
+ uploadDocumentsPermission: true,
+ workOrders: 0,
+ },
+ {
+ id: "101",
+ projectWorkstreamId: "11",
+ workstreamName: "Roofs",
+ updateStagesPermission: true,
+ uploadDocumentsPermission: true,
+ 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 = (organisationId = APEX) => ({
+ params: Promise.resolve({ projectId: "5", organisationId }),
+});
+
+function url(confirm = false, organisationId = APEX) {
+ return `http://test/api/projects/5/contractors/${organisationId}${
+ confirm ? "?confirm=true" : ""
+ }`;
+}
+
+/** One grid row: a workstream and the permissions it should carry. */
+function ws(
+ projectWorkstreamId: string,
+ updateStagesPermission = false,
+ uploadDocumentsPermission = false,
+) {
+ return {
+ projectWorkstreamId,
+ updateStagesPermission,
+ uploadDocumentsPermission,
+ };
+}
+
+function patchRequest(body: unknown, confirm = false) {
+ return new NextRequest(url(confirm), {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ });
+}
+
+function deleteRequest(confirm = false, organisationId = APEX) {
+ return new NextRequest(url(confirm, organisationId), { method: "DELETE" });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockFindContractorAssignments.mockResolvedValue(CURRENT);
+ mockListProjectWorkstreams.mockResolvedValue(WORKSTREAMS);
+ // By default another contractor covers everything Apex would give up.
+ mockCountOtherContractors.mockResolvedValue({ "10": 1, "11": 1 });
+ mockWriteContractorAssignments.mockResolvedValue({
+ created: 0,
+ updated: 1,
+ removed: 1,
+ });
+ mockDeleteAssignments.mockResolvedValue(2);
+});
+
+describe("PATCH", () => {
+ it("replaces the contractor's workstreams and permissions", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10", false, true)] }),
+ params(),
+ );
+
+ expect(res.status).toBe(200);
+ expect(mockWriteContractorAssignments).toHaveBeenCalledWith(
+ 5n,
+ APEX,
+ [
+ {
+ projectWorkstreamId: 10n,
+ updateStagesPermission: false,
+ uploadDocumentsPermission: true,
+ },
+ ],
+ "replace",
+ );
+ });
+
+ it("does not consult the removal rules when nothing is dropped", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10"), ws("11")] }),
+ params(),
+ );
+
+ expect(res.status).toBe(200);
+ expect(mockCountOtherContractors).not.toHaveBeenCalled();
+ });
+
+ it("409s for confirmation when dropping a workstream strands it", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockCountOtherContractors.mockResolvedValue({ "11": 0 });
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10")] }),
+ params(),
+ );
+
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body).toMatchObject({ requiresConfirmation: true, blocked: false });
+ expect(body.error).toContain("Roofs");
+ expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
+ });
+
+ it("writes once the stranding warning is confirmed", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockCountOtherContractors.mockResolvedValue({ "11": 0 });
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10")] }, true),
+ params(),
+ );
+
+ expect(res.status).toBe(200);
+ expect(mockWriteContractorAssignments).toHaveBeenCalled();
+ });
+
+ it("blocks dropping a workstream with work orders, even when confirmed", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockFindContractorAssignments.mockResolvedValue([
+ CURRENT[0],
+ { ...CURRENT[1], workOrders: 4 },
+ ]);
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10")] }, true),
+ params(),
+ );
+
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body).toMatchObject({ blocked: true, requiresConfirmation: false });
+ expect(body.error).toContain("4 work orders");
+ expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
+ });
+
+ it("404s a contractor that is not assigned to the project", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockFindContractorAssignments.mockResolvedValue([]);
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10")] }),
+ params(),
+ );
+
+ expect(res.status).toBe(404);
+ });
+
+ it("404s a workstream belonging to another project", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("999")] }),
+ params(),
+ );
+
+ expect(res.status).toBe(404);
+ expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
+ });
+
+ it("400s an empty workstream list — that is what DELETE is for", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await PATCH(patchRequest({ assignments: [] }), params());
+
+ expect(res.status).toBe(400);
+ });
+
+ it("400s a contractor id that is not a uuid", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10")] }),
+ params("apex"),
+ );
+
+ expect(res.status).toBe(400);
+ });
+
+ it("403s a contractor editing assignments", async () => {
+ signedInAs([CONTRACTOR_ORG]);
+ projectExists();
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10")] }),
+ params(),
+ );
+
+ expect(res.status).toBe(403);
+ });
+
+ it("404s a project the caller has no standing on", async () => {
+ signedInAs([OTHER_ORG]);
+ projectExists();
+
+ const res = await PATCH(
+ patchRequest({ assignments: [ws("10")] }),
+ params(),
+ );
+
+ expect(res.status).toBe(404);
+ });
+});
+
+describe("DELETE", () => {
+ it("removes every assignment the contractor holds on the project", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await DELETE(deleteRequest(), params());
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({
+ organisationId: APEX,
+ removed: 2,
+ });
+ expect(mockDeleteAssignments).toHaveBeenCalledWith([100n, 101n]);
+ });
+
+ it("409s for confirmation when removal strands a workstream", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockCountOtherContractors.mockResolvedValue({ "10": 1 });
+
+ const res = await DELETE(deleteRequest(), params());
+
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body.requiresConfirmation).toBe(true);
+ expect(body.error).toContain("Roofs");
+ expect(mockDeleteAssignments).not.toHaveBeenCalled();
+ });
+
+ it("removes once confirmed", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockCountOtherContractors.mockResolvedValue({});
+
+ const res = await DELETE(deleteRequest(true), params());
+
+ expect(res.status).toBe(200);
+ expect(mockDeleteAssignments).toHaveBeenCalled();
+ });
+
+ it("blocks removing a contractor that has work orders", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockFindContractorAssignments.mockResolvedValue([
+ { ...CURRENT[0], workOrders: 1 },
+ CURRENT[1],
+ ]);
+
+ const res = await DELETE(deleteRequest(true), params());
+
+ expect(res.status).toBe(409);
+ await expect(res.json()).resolves.toMatchObject({ blocked: true });
+ expect(mockDeleteAssignments).not.toHaveBeenCalled();
+ });
+
+ it("404s a contractor that is not assigned to the project", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockFindContractorAssignments.mockResolvedValue([]);
+
+ const res = await DELETE(deleteRequest(), params());
+
+ expect(res.status).toBe(404);
+ });
+
+ it("403s a contractor removing anyone", async () => {
+ signedInAs([CONTRACTOR_ORG]);
+ projectExists();
+
+ const res = await DELETE(deleteRequest(), params());
+
+ expect(res.status).toBe(403);
+ expect(mockDeleteAssignments).not.toHaveBeenCalled();
+ });
+
+ it("500s, without leaking the error, when the delete fails", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockDeleteAssignments.mockRejectedValue(new Error("pg exploded"));
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ const res = await DELETE(deleteRequest(), params());
+
+ expect(res.status).toBe(500);
+ await expect(res.json()).resolves.toEqual({
+ error: "Couldn't remove the contractor",
+ });
+ consoleError.mockRestore();
+ });
+});
diff --git a/src/app/api/projects/[projectId]/contractors/[organisationId]/route.ts b/src/app/api/projects/[projectId]/contractors/[organisationId]/route.ts
new file mode 100644
index 00000000..6ba1c0c0
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/[organisationId]/route.ts
@@ -0,0 +1,181 @@
+/**
+ * `/api/projects/[projectId]/contractors/[organisationId]` (issue #413).
+ *
+ * PATCH — edit one contractor: which workstreams it delivers and what it may
+ * do on them. Replace semantics — the body is the assignment set the
+ * contractor should end up with.
+ * DELETE — remove the contractor from the project entirely.
+ *
+ * Both routes are removal paths, so both consult `decideRemoval` before they
+ * touch anything (see `../assignments` for the two rules). A refusal or a
+ * warning is a **409** carrying the reason:
+ * - `{ blocked: true }` — work orders exist; confirming won't help.
+ * - `{ requiresConfirmation: true }` — retry with `?confirm=true`.
+ * That is the same shape the workstream-deselect endpoint (#410) uses, so the
+ * client handles both the same way.
+ */
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { authorizeContractorRequest } from "../authorize";
+import { assignmentBodySchema, type AssignmentBody } from "../validation";
+import { decideRemoval, type ContractorAssignment } from "../assignments";
+import {
+ countOtherContractors,
+ deleteAssignments,
+ findContractorAssignments,
+ listProjectWorkstreams,
+ writeContractorAssignments,
+} from "../queries";
+
+type Params = {
+ params: Promise<{ projectId: string; organisationId: string }>;
+};
+
+/** Turn a removal decision into the 409 the client knows how to read. */
+async function refuseRemoval(
+ projectId: bigint,
+ organisationId: string,
+ removed: ContractorAssignment[],
+ confirmed: boolean,
+): Promise {
+ if (removed.length === 0) return null;
+
+ const remainingContractors = await countOtherContractors(
+ removed.map((a) => BigInt(a.projectWorkstreamId)),
+ organisationId,
+ );
+ const decision = decideRemoval({ removed, remainingContractors }, confirmed);
+ if (decision.kind === "allowed") return null;
+
+ return NextResponse.json(
+ {
+ error: decision.reason,
+ blocked: decision.kind === "blocked",
+ requiresConfirmation: decision.kind === "needs-confirmation",
+ },
+ { status: 409 },
+ );
+}
+
+/**
+ * PATCH — set exactly which workstreams this contractor delivers, and the
+ * permissions each of those carries.
+ *
+ * Workstreams dropped from the set are removed, so the removal rules apply to
+ * them. An empty set would be a removal of the whole contractor; that is what
+ * DELETE is for, and the schema rejects it here rather than quietly doing it.
+ */
+export async function PATCH(req: NextRequest, props: Params) {
+ const { projectId, organisationId } = await props.params;
+ const auth = await authorizeContractorRequest(projectId, "manage");
+ if (!auth.ok) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status });
+ }
+
+ if (!z.string().uuid().safeParse(organisationId).success) {
+ return NextResponse.json({ error: "Invalid contractor" }, { status: 400 });
+ }
+
+ let body: AssignmentBody;
+ try {
+ body = assignmentBodySchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ const current = await findContractorAssignments(auth.projectId, organisationId);
+ if (current.length === 0) {
+ return NextResponse.json(
+ { error: "Contractor not assigned to this project" },
+ { status: 404 },
+ );
+ }
+
+ const owned = new Set(
+ (await listProjectWorkstreams(auth.projectId)).map(
+ (w) => w.projectWorkstreamId,
+ ),
+ );
+ if (body.assignments.some((a) => !owned.has(a.projectWorkstreamId))) {
+ return NextResponse.json(
+ { error: "Workstream not found on this project" },
+ { status: 404 },
+ );
+ }
+
+ const wanted = new Set(body.assignments.map((a) => a.projectWorkstreamId));
+ const dropped = current.filter((a) => !wanted.has(a.projectWorkstreamId));
+ const refusal = await refuseRemoval(
+ auth.projectId,
+ organisationId,
+ dropped,
+ req.nextUrl.searchParams.get("confirm") === "true",
+ );
+ if (refusal) return refusal;
+
+ try {
+ const result = await writeContractorAssignments(
+ auth.projectId,
+ organisationId,
+ body.assignments.map((a) => ({
+ projectWorkstreamId: BigInt(a.projectWorkstreamId),
+ updateStagesPermission: a.updateStagesPermission,
+ uploadDocumentsPermission: a.uploadDocumentsPermission,
+ })),
+ "replace",
+ );
+ return NextResponse.json({ organisationId, ...result });
+ } catch (err) {
+ console.error(
+ "PATCH /api/projects/[projectId]/contractors/[organisationId] failed:",
+ err,
+ );
+ return NextResponse.json(
+ { error: "Couldn't update the contractor" },
+ { status: 500 },
+ );
+ }
+}
+
+/** DELETE — take the contractor off the project, all workstreams at once. */
+export async function DELETE(req: NextRequest, props: Params) {
+ const { projectId, organisationId } = await props.params;
+ const auth = await authorizeContractorRequest(projectId, "manage");
+ if (!auth.ok) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status });
+ }
+
+ if (!z.string().uuid().safeParse(organisationId).success) {
+ return NextResponse.json({ error: "Invalid contractor" }, { status: 400 });
+ }
+
+ const current = await findContractorAssignments(auth.projectId, organisationId);
+ if (current.length === 0) {
+ return NextResponse.json(
+ { error: "Contractor not assigned to this project" },
+ { status: 404 },
+ );
+ }
+
+ const refusal = await refuseRemoval(
+ auth.projectId,
+ organisationId,
+ current,
+ req.nextUrl.searchParams.get("confirm") === "true",
+ );
+ if (refusal) return refusal;
+
+ try {
+ const removed = await deleteAssignments(current.map((a) => BigInt(a.id)));
+ return NextResponse.json({ organisationId, removed });
+ } catch (err) {
+ console.error(
+ "DELETE /api/projects/[projectId]/contractors/[organisationId] failed:",
+ err,
+ );
+ return NextResponse.json(
+ { error: "Couldn't remove the contractor" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/projects/[projectId]/contractors/assignments.test.ts b/src/app/api/projects/[projectId]/contractors/assignments.test.ts
new file mode 100644
index 00000000..1c3db078
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/assignments.test.ts
@@ -0,0 +1,254 @@
+/**
+ * The pure contractor-assignment rules (issue #413). No database, no mocks —
+ * the module imports neither.
+ */
+import { describe, expect, it } from "vitest";
+import {
+ decideRemoval,
+ groupByContractor,
+ listNames,
+ type ContractorAssignment,
+ type ContractorAssignmentRow,
+} from "./assignments";
+
+const APEX = "11111111-1111-1111-1111-111111111111";
+const ELEVATE = "22222222-2222-2222-2222-222222222222";
+
+function row(
+ overrides: Partial = {},
+): ContractorAssignmentRow {
+ return {
+ id: "1",
+ projectWorkstreamId: "10",
+ workstreamName: "Windows",
+ organisationId: APEX,
+ organisationName: "Apex Construction",
+ updateStagesPermission: true,
+ uploadDocumentsPermission: true,
+ workOrders: 0,
+ ...overrides,
+ };
+}
+
+function assignment(
+ overrides: Partial = {},
+): ContractorAssignment {
+ return {
+ id: "1",
+ projectWorkstreamId: "10",
+ workstreamName: "Windows",
+ updateStagesPermission: true,
+ uploadDocumentsPermission: true,
+ workOrders: 0,
+ ...overrides,
+ };
+}
+
+describe("groupByContractor", () => {
+ it("gathers per-workstream rows under one row per organisation", () => {
+ const groups = groupByContractor([
+ row({ id: "1", projectWorkstreamId: "10", workstreamName: "Windows" }),
+ row({ id: "2", projectWorkstreamId: "11", workstreamName: "Roofs" }),
+ ]);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].organisationName).toBe("Apex Construction");
+ expect(groups[0].assignments.map((a) => a.workstreamName)).toEqual([
+ "Roofs",
+ "Windows",
+ ]);
+ });
+
+ it("orders organisations by name and workstreams by name", () => {
+ const groups = groupByContractor([
+ row({ id: "1", workstreamName: "Windows" }),
+ row({
+ id: "2",
+ projectWorkstreamId: "11",
+ workstreamName: "Doors",
+ organisationId: ELEVATE,
+ organisationName: "Elevate Electric",
+ }),
+ row({ id: "3", projectWorkstreamId: "12", workstreamName: "Asbestos" }),
+ ]);
+
+ expect(groups.map((g) => g.organisationName)).toEqual([
+ "Apex Construction",
+ "Elevate Electric",
+ ]);
+ expect(groups[0].assignments.map((a) => a.workstreamName)).toEqual([
+ "Asbestos",
+ "Windows",
+ ]);
+ });
+
+ it("keeps each assignment's own permissions rather than reducing them", () => {
+ const groups = groupByContractor([
+ row({
+ id: "1",
+ workstreamName: "Windows",
+ updateStagesPermission: true,
+ uploadDocumentsPermission: false,
+ }),
+ row({
+ id: "2",
+ projectWorkstreamId: "11",
+ workstreamName: "Roofs",
+ updateStagesPermission: false,
+ uploadDocumentsPermission: true,
+ }),
+ ]);
+
+ expect(groups[0].assignments).toEqual([
+ expect.objectContaining({
+ workstreamName: "Roofs",
+ updateStagesPermission: false,
+ uploadDocumentsPermission: true,
+ }),
+ expect.objectContaining({
+ workstreamName: "Windows",
+ updateStagesPermission: true,
+ uploadDocumentsPermission: false,
+ }),
+ ]);
+ // No per-contractor summary exists to disagree with the detail.
+ expect(groups[0]).not.toHaveProperty("updateStages");
+ expect(groups[0]).not.toHaveProperty("uploadDocuments");
+ });
+
+ it("totals work orders across the contractor's assignments", () => {
+ const groups = groupByContractor([
+ row({ id: "1", workOrders: 3 }),
+ row({ id: "2", projectWorkstreamId: "11", workOrders: 4 }),
+ ]);
+
+ expect(groups[0].workOrders).toBe(7);
+ });
+
+ it("returns nothing for a project with no assignments", () => {
+ expect(groupByContractor([])).toEqual([]);
+ });
+});
+
+describe("listNames", () => {
+ it("joins with commas and a final 'and'", () => {
+ expect(listNames([])).toBe("");
+ expect(listNames(["Windows"])).toBe("Windows");
+ expect(listNames(["Windows", "Roofs"])).toBe("Windows and Roofs");
+ expect(listNames(["Windows", "Roofs", "Doors"])).toBe(
+ "Windows, Roofs and Doors",
+ );
+ });
+});
+
+describe("decideRemoval", () => {
+ it("allows a removal that strands no workstream", () => {
+ const decision = decideRemoval(
+ {
+ removed: [assignment()],
+ remainingContractors: { "10": 1 },
+ },
+ false,
+ );
+
+ expect(decision).toEqual({ kind: "allowed" });
+ });
+
+ it("warns when the workstream would be left with no contractor", () => {
+ const decision = decideRemoval(
+ {
+ removed: [assignment({ workstreamName: "Windows" })],
+ remainingContractors: { "10": 0 },
+ },
+ false,
+ );
+
+ expect(decision.kind).toBe("needs-confirmation");
+ if (decision.kind !== "allowed") {
+ expect(decision.reason).toContain("Windows is left with no contractor");
+ expect(decision.reason).toContain("imported");
+ }
+ });
+
+ it("reads a missing remaining-count as zero", () => {
+ const decision = decideRemoval(
+ { removed: [assignment()], remainingContractors: {} },
+ false,
+ );
+
+ expect(decision.kind).toBe("needs-confirmation");
+ });
+
+ it("proceeds once the stranding warning is confirmed", () => {
+ const decision = decideRemoval(
+ { removed: [assignment()], remainingContractors: { "10": 0 } },
+ true,
+ );
+
+ expect(decision).toEqual({ kind: "allowed" });
+ });
+
+ it("blocks a removal whose assignment has work orders, naming the count", () => {
+ const decision = decideRemoval(
+ {
+ removed: [assignment({ workOrders: 12, workstreamName: "Roofs" })],
+ remainingContractors: { "10": 3 },
+ },
+ false,
+ );
+
+ expect(decision.kind).toBe("blocked");
+ if (decision.kind !== "allowed") {
+ expect(decision.reason).toContain("12 work orders have");
+ expect(decision.reason).toContain("Roofs");
+ }
+ });
+
+ it("keeps blocking work orders even when the user confirms", () => {
+ const decision = decideRemoval(
+ {
+ removed: [assignment({ workOrders: 1 })],
+ remainingContractors: { "10": 0 },
+ },
+ true,
+ );
+
+ expect(decision.kind).toBe("blocked");
+ });
+
+ it("sums work orders across the assignments being removed", () => {
+ const decision = decideRemoval(
+ {
+ removed: [
+ assignment({ id: "1", workOrders: 2, workstreamName: "Windows" }),
+ assignment({
+ id: "2",
+ projectWorkstreamId: "11",
+ workOrders: 3,
+ workstreamName: "Roofs",
+ }),
+ ],
+ remainingContractors: { "10": 1, "11": 1 },
+ },
+ false,
+ );
+
+ expect(decision.kind).toBe("blocked");
+ if (decision.kind !== "allowed") {
+ expect(decision.reason).toContain("5 work orders");
+ expect(decision.reason).toContain("Windows and Roofs");
+ }
+ });
+
+ it("ignores assignments that are being kept when counting work orders", () => {
+ const decision = decideRemoval(
+ {
+ removed: [assignment({ workOrders: 0 })],
+ remainingContractors: { "10": 2 },
+ },
+ false,
+ );
+
+ expect(decision).toEqual({ kind: "allowed" });
+ });
+});
diff --git a/src/app/api/projects/[projectId]/contractors/assignments.ts b/src/app/api/projects/[projectId]/contractors/assignments.ts
new file mode 100644
index 00000000..2937705e
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/assignments.ts
@@ -0,0 +1,178 @@
+/**
+ * Contractor-assignment rules for a Project (issue #413).
+ *
+ * Deliberately **pure** — it imports no database client and no React — so the
+ * route handlers, the unit tests and the browser bundle all share one copy of
+ * the rules. The rows themselves are loaded in `./queries`.
+ *
+ * Two things live here:
+ *
+ * 1. **Grouping.** `project_workstream_contractor` is one row per
+ * (Project Workstream, organisation) pair. The table has one row per
+ * *contractor organisation*, listing the workstreams it delivers, and
+ * expands to show the permissions each of those assignments carries — so
+ * grouping rearranges the rows without ever collapsing their permissions
+ * into a single per-contractor answer (ADR-0022).
+ *
+ * 2. **Removal.** Work orders reference their assignment by a NOT NULL FK
+ * (`work_order.project_workstream_contractor_id`), so an assignment with
+ * work orders against it cannot be deleted without cascading through
+ * issued work — which #413 explicitly rules out. Such a removal is
+ * therefore refused, with the count named, rather than attempted. A
+ * removal that merely leaves a workstream with no contractor is allowed,
+ * but warns first: that is exactly what the import gate gets stuck on
+ * (ADR-0019), so the user should hear it before it happens, not on the
+ * import screen afterwards.
+ */
+
+/** One `project_workstream_contractor` row, flattened and serialised. */
+export interface ContractorAssignment {
+ /** `project_workstream_contractor.id`. */
+ id: string;
+ /** `project_workstream.id` — the workstream this assignment delivers. */
+ projectWorkstreamId: string;
+ /** `workstream.name`, for the chips and every warning sentence. */
+ workstreamName: string;
+ updateStagesPermission: boolean;
+ uploadDocumentsPermission: boolean;
+ /** `work_order` rows issued against this assignment. Blocks removal. */
+ workOrders: number;
+}
+
+/** An assignment together with the organisation holding it. */
+export interface ContractorAssignmentRow extends ContractorAssignment {
+ organisationId: string;
+ organisationName: string;
+}
+
+/** One row of the permissions table: a contractor and everything it delivers. */
+export interface ContractorGroup {
+ organisationId: string;
+ organisationName: string;
+ /** This contractor's assignments, ordered by workstream name. */
+ assignments: ContractorAssignment[];
+ /** Work orders across every assignment — non-zero means it cannot be removed. */
+ workOrders: number;
+}
+
+/**
+ * Gather per-workstream assignment rows under one row per contractor
+ * organisation, ordered by organisation name then workstream name so the table
+ * is stable between refetches.
+ *
+ * The permissions stay on the assignments. There is deliberately no
+ * per-contractor summary of them: the table's collapsed row shows who delivers
+ * what, and the expanded sub-row shows each assignment's own permissions
+ * (ADR-0022). Nothing needs the two flags reduced to one answer, and reducing
+ * them is how a contractor allowed to update stages on one workstream ends up
+ * looking allowed everywhere.
+ */
+export function groupByContractor(
+ rows: ContractorAssignmentRow[],
+): ContractorGroup[] {
+ const groups = new Map();
+
+ for (const row of rows) {
+ let group = groups.get(row.organisationId);
+ if (!group) {
+ group = {
+ organisationId: row.organisationId,
+ organisationName: row.organisationName,
+ assignments: [],
+ workOrders: 0,
+ };
+ groups.set(row.organisationId, group);
+ }
+ group.assignments.push({
+ id: row.id,
+ projectWorkstreamId: row.projectWorkstreamId,
+ workstreamName: row.workstreamName,
+ updateStagesPermission: row.updateStagesPermission,
+ uploadDocumentsPermission: row.uploadDocumentsPermission,
+ workOrders: row.workOrders,
+ });
+ }
+
+ return [...groups.values()]
+ .map((group) => {
+ group.assignments.sort((a, b) =>
+ a.workstreamName.localeCompare(b.workstreamName),
+ );
+ return {
+ ...group,
+ workOrders: group.assignments.reduce((n, a) => n + a.workOrders, 0),
+ };
+ })
+ .sort((a, b) => a.organisationName.localeCompare(b.organisationName));
+}
+
+/** Which of the two flags is being talked about. */
+export type PermissionKey =
+ | "updateStagesPermission"
+ | "uploadDocumentsPermission";
+
+/** `"Windows, Roofs and Doors"`. */
+export function listNames(names: string[]): string {
+ if (names.length === 0) return "";
+ if (names.length === 1) return names[0];
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
+}
+
+export type RemovalDecision =
+ | { kind: "blocked"; reason: string }
+ | { kind: "needs-confirmation"; reason: string }
+ | { kind: "allowed" };
+
+/** The facts a removal is judged on. */
+export interface RemovalContext {
+ /** The assignments the request would delete. */
+ removed: ContractorAssignment[];
+ /**
+ * Keyed by `project_workstream.id`: how many *other* contractors would still
+ * be assigned to that workstream once these rows are gone. A key missing
+ * from the record is read as zero.
+ */
+ remainingContractors: Record;
+}
+
+/**
+ * Whether a removal may proceed.
+ *
+ * `confirmed` is the user having acknowledged the warning (`?confirm=true`).
+ * It never unblocks a work-order block: that is a hard rule, because the FK
+ * makes the delete impossible, not merely unwise.
+ */
+export function decideRemoval(
+ context: RemovalContext,
+ confirmed: boolean,
+): RemovalDecision {
+ const withWork = context.removed.filter((a) => a.workOrders > 0);
+ if (withWork.length > 0) {
+ const total = withWork.reduce((n, a) => n + a.workOrders, 0);
+ const plural = total === 1 ? "work order has" : "work orders have";
+ return {
+ kind: "blocked",
+ reason:
+ `${total} ${plural} already been issued to this contractor for ` +
+ `${listNames(withWork.map((a) => a.workstreamName))}. ` +
+ `Those work orders keep pointing at this assignment, so it can't be ` +
+ `removed — reassign or remove the work orders first.`,
+ };
+ }
+
+ const stranded = context.removed
+ .filter((a) => (context.remainingContractors[a.projectWorkstreamId] ?? 0) === 0)
+ .map((a) => a.workstreamName);
+
+ if (!confirmed && stranded.length > 0) {
+ const plural = stranded.length === 1 ? "is" : "are";
+ return {
+ kind: "needs-confirmation",
+ reason:
+ `${listNames(stranded)} ${plural} left with no contractor. ` +
+ `Work orders can't be imported until every workstream has one.`,
+ };
+ }
+
+ return { kind: "allowed" };
+}
diff --git a/src/app/api/projects/[projectId]/contractors/authorize.ts b/src/app/api/projects/[projectId]/contractors/authorize.ts
new file mode 100644
index 00000000..0459cbd6
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/authorize.ts
@@ -0,0 +1,16 @@
+/**
+ * Request authorization for the contractor-assignment endpoints (issue #413).
+ *
+ * The rule is the project-level one #408 already owns — view to read the
+ * table, manage to change it — and #410 already wired it up for the
+ * workstream-selection endpoints. Assigning a contractor is administering the
+ * project, exactly what `canManageProject` covers, so this re-exports that
+ * wiring under a name that reads right here rather than restating it. Nothing
+ * in this feature re-implements a permission rule.
+ */
+export {
+ authorizeWorkstreamRequest as authorizeContractorRequest,
+ type AuthorizeResult,
+ type AuthorizeSuccess,
+ type AuthorizeFailure,
+} from "@/app/api/projects/[projectId]/workstreams/authorize";
diff --git a/src/app/api/projects/[projectId]/contractors/organisations/route.test.ts b/src/app/api/projects/[projectId]/contractors/organisations/route.test.ts
new file mode 100644
index 00000000..abc683c8
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/organisations/route.test.ts
@@ -0,0 +1,112 @@
+/**
+ * GET `/api/projects/[projectId]/contractors/organisations` (issue #413).
+ *
+ * The search itself is mocked at `../queries`; what is tested here is that the
+ * picker is behind the manage permission and passes the query through.
+ */
+import { describe, expect, it, beforeEach, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const {
+ mockGetServerSession,
+ mockLoadAuthzUser,
+ mockLoadProjectAuthzFacts,
+ mockSearchOrganisations,
+} = vi.hoisted(() => ({
+ mockGetServerSession: vi.fn(),
+ mockLoadAuthzUser: vi.fn(),
+ mockLoadProjectAuthzFacts: vi.fn(),
+ mockSearchOrganisations: 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", () => ({
+ searchOrganisations: mockSearchOrganisations,
+}));
+
+import { GET } from "./route";
+
+const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
+const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
+const APEX = "44444444-4444-4444-4444-444444444444";
+
+const RESULTS = [{ id: APEX, name: "Apex Construction", alreadyAssigned: false }];
+
+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 = { params: Promise.resolve({ projectId: "5" }) };
+
+function request(query = "") {
+ return new NextRequest(
+ `http://test/api/projects/5/contractors/organisations${query}`,
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockSearchOrganisations.mockResolvedValue(RESULTS);
+});
+
+describe("GET .../contractors/organisations", () => {
+ it("searches by the q parameter", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await GET(request("?q=apex"), params);
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({
+ organisations: RESULTS,
+ limit: 20,
+ });
+ expect(mockSearchOrganisations).toHaveBeenCalledWith(5n, "apex", 20);
+ });
+
+ it("treats a missing q as the empty search", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ await GET(request(), params);
+
+ expect(mockSearchOrganisations).toHaveBeenCalledWith(5n, "", 20);
+ });
+
+ it("403s a contractor — only managers may enumerate organisations", async () => {
+ signedInAs([CONTRACTOR_ORG]);
+ projectExists();
+
+ const res = await GET(request("?q=apex"), params);
+
+ expect(res.status).toBe(403);
+ expect(mockSearchOrganisations).not.toHaveBeenCalled();
+ });
+
+ it("401s without a session", async () => {
+ mockGetServerSession.mockResolvedValue(null);
+
+ const res = await GET(request("?q=apex"), params);
+
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/src/app/api/projects/[projectId]/contractors/organisations/route.ts b/src/app/api/projects/[projectId]/contractors/organisations/route.ts
new file mode 100644
index 00000000..3f13f07e
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/organisations/route.ts
@@ -0,0 +1,38 @@
+/**
+ * `/api/projects/[projectId]/contractors/organisations?q=` (issue #413).
+ *
+ * The organisation picker behind "Add contractor". **Read-only, deliberately**:
+ * a contractor is an existing `organisation` (CONTEXT.md, "Contractor"), and
+ * creating one belongs to the "invite company to Ara" flow, not to project
+ * setup. There is no POST here and there should not be one.
+ *
+ * It is scoped to a project only for permission and for the `alreadyAssigned`
+ * flag — the organisation table itself is global.
+ */
+import { NextRequest, NextResponse } from "next/server";
+import { authorizeContractorRequest } from "../authorize";
+import { searchOrganisations } from "../queries";
+
+type Params = { params: Promise<{ projectId: string }> };
+
+/** How many matches the picker shows before asking the user to type more. */
+const RESULT_LIMIT = 20;
+
+export async function GET(req: NextRequest, props: Params) {
+ const { projectId } = await props.params;
+ // Manage, not view: only someone who could actually assign a contractor has
+ // any business enumerating organisations through this route.
+ const auth = await authorizeContractorRequest(projectId, "manage");
+ if (!auth.ok) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status });
+ }
+
+ const q = req.nextUrl.searchParams.get("q") ?? "";
+ const organisations = await searchOrganisations(
+ auth.projectId,
+ q,
+ RESULT_LIMIT,
+ );
+
+ return NextResponse.json({ organisations, limit: RESULT_LIMIT });
+}
diff --git a/src/app/api/projects/[projectId]/contractors/permissions.test.ts b/src/app/api/projects/[projectId]/contractors/permissions.test.ts
new file mode 100644
index 00000000..756e86e3
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/permissions.test.ts
@@ -0,0 +1,148 @@
+/**
+ * The seam between what this feature *writes* and what the authz guards
+ * *read* (issue #413 acceptance: "permission toggles take effect in the authz
+ * lib guards").
+ *
+ * The two ends are tested separately elsewhere — the guards' own rules in
+ * `@/lib/projects/authz`'s tests, the endpoints' behaviour in `./route.test.ts`
+ * — and neither notices if they stop describing the same booleans. This does:
+ * it starts from a `project_workstream_contractor` row typed against the real
+ * schema, flattens it exactly as `./queries` does, and asks the real guards
+ * what a contractor may do. Renaming a column or a wire field breaks it, at
+ * compile time or on the assertion.
+ *
+ * No database: the row is a literal, `InferModel` only supplies its type.
+ */
+import { describe, expect, it } from "vitest";
+import type { ProjectWorkstreamContractor } from "@/app/db/schema/projects/projects";
+import {
+ canUpdateStage,
+ canUploadEvidence,
+ type AuthzUser,
+ type ProjectAuthzFacts,
+ type WorkOrderAuthzFacts,
+} from "@/lib/projects/authz";
+import type { ContractorAssignment } from "./assignments";
+
+const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
+const APEX = "44444444-4444-4444-4444-444444444444";
+
+/** Someone signed in for the contractor organisation, and nothing else. */
+const CONTRACTOR_USER: AuthzUser = {
+ id: 7n,
+ email: "site@apex.example",
+ organisationIds: [APEX],
+};
+
+const PROJECT: ProjectAuthzFacts = {
+ id: 5n,
+ organisationId: CLIENT_ORG,
+ domnaAdminAccess: false,
+ contractorOrganisationIds: [APEX],
+};
+
+/** A row as the table stores it, after the toggles have been set. */
+function storedRow(
+ updateStagesPermission: boolean,
+ uploadDocumentsPermission: boolean,
+): ProjectWorkstreamContractor {
+ return {
+ id: 100n,
+ projectWorkstreamId: 10n,
+ organisationId: APEX,
+ updateStagesPermission,
+ uploadDocumentsPermission,
+ assignedAt: new Date("2026-07-23T09:00:00Z"),
+ };
+}
+
+/** The flattening `loadAssignmentRows` performs, in one place for the test. */
+function toWireAssignment(row: ProjectWorkstreamContractor): ContractorAssignment {
+ return {
+ id: row.id.toString(),
+ projectWorkstreamId: row.projectWorkstreamId.toString(),
+ workstreamName: "Windows",
+ updateStagesPermission: row.updateStagesPermission,
+ uploadDocumentsPermission: row.uploadDocumentsPermission,
+ workOrders: 0,
+ };
+}
+
+/** A work order issued under that assignment, as the guards read it. */
+function workOrderUnder(
+ assignment: ContractorAssignment,
+ organisationId = APEX,
+): WorkOrderAuthzFacts {
+ return {
+ id: 900n,
+ contractorOrganisationId: organisationId,
+ updateStagesPermission: assignment.updateStagesPermission,
+ uploadDocumentsPermission: assignment.uploadDocumentsPermission,
+ };
+}
+
+describe("the permission toggles reach the authz guards", () => {
+ it("lets the contractor update stages only when the assignment grants it", () => {
+ const granted = toWireAssignment(storedRow(true, false));
+ const withheld = toWireAssignment(storedRow(false, false));
+
+ expect(
+ canUpdateStage(CONTRACTOR_USER, PROJECT, workOrderUnder(granted)),
+ ).toBe(true);
+ expect(
+ canUpdateStage(CONTRACTOR_USER, PROJECT, workOrderUnder(withheld)),
+ ).toBe(false);
+ });
+
+ it("lets the contractor upload evidence only when the assignment grants it", () => {
+ const granted = toWireAssignment(storedRow(false, true));
+ const withheld = toWireAssignment(storedRow(false, false));
+
+ expect(
+ canUploadEvidence(CONTRACTOR_USER, PROJECT, workOrderUnder(granted)),
+ ).toBe(true);
+ expect(
+ canUploadEvidence(CONTRACTOR_USER, PROJECT, workOrderUnder(withheld)),
+ ).toBe(false);
+ });
+
+ it("keeps the two permissions independent", () => {
+ const stagesOnly = toWireAssignment(storedRow(true, false));
+
+ expect(
+ canUpdateStage(CONTRACTOR_USER, PROJECT, workOrderUnder(stagesOnly)),
+ ).toBe(true);
+ expect(
+ canUploadEvidence(CONTRACTOR_USER, PROJECT, workOrderUnder(stagesOnly)),
+ ).toBe(false);
+ });
+
+ it("does not let a permission leak to a work order issued to another contractor", () => {
+ const granted = toWireAssignment(storedRow(true, true));
+ const someoneElse = "55555555-5555-5555-5555-555555555555";
+
+ expect(
+ canUpdateStage(
+ CONTRACTOR_USER,
+ { ...PROJECT, contractorOrganisationIds: [APEX, someoneElse] },
+ workOrderUnder(granted, someoneElse),
+ ),
+ ).toBe(false);
+ });
+
+ it("leaves the client organisation unaffected by the toggles", () => {
+ const withheld = toWireAssignment(storedRow(false, false));
+ const clientUser: AuthzUser = {
+ id: 8n,
+ email: "asset@landlord.example",
+ organisationIds: [CLIENT_ORG],
+ };
+
+ expect(
+ canUpdateStage(clientUser, PROJECT, workOrderUnder(withheld)),
+ ).toBe(true);
+ expect(
+ canUploadEvidence(clientUser, PROJECT, workOrderUnder(withheld)),
+ ).toBe(true);
+ });
+});
diff --git a/src/app/api/projects/[projectId]/contractors/queries.ts b/src/app/api/projects/[projectId]/contractors/queries.ts
new file mode 100644
index 00000000..20cdccac
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/queries.ts
@@ -0,0 +1,412 @@
+/**
+ * Persistence for contractor assignment (issue #413).
+ *
+ * The route handlers and the server-rendered setup screen both read through
+ * here, so the first paint and every refetch afterwards agree on shape — the
+ * same split `./queries` gives the workstream-selection step (#410).
+ *
+ * Nothing in this file decides anything: the grouping and the removal rules
+ * live in `./assignments`, which imports no database client. Ids are bigint in
+ * the database and strings on the wire, because bigint does not survive JSON —
+ * the conversion happens here, at the boundary, and nowhere else.
+ */
+import { db } from "@/app/db/db";
+import { and, eq, ilike, inArray, isNotNull, ne, sql } from "drizzle-orm";
+import {
+ projectWorkstream,
+ projectWorkstreamContractor,
+ workOrder,
+ workstream,
+} from "@/app/db/schema/projects/projects";
+import { organisation } from "@/app/db/schema/organisation";
+import {
+ groupByContractor,
+ type ContractorAssignment,
+ type ContractorAssignmentRow,
+ type ContractorGroup,
+} from "./assignments";
+
+/** One selectable workstream in the add/edit dialog. */
+export interface ProjectWorkstreamOption {
+ /** `project_workstream.id`, serialised. */
+ projectWorkstreamId: string;
+ /** `workstream.name`. */
+ name: string;
+ /** How many contractors are assigned to it — 0 is what blocks import. */
+ contractorCount: number;
+}
+
+/** One result of the organisation search behind "Add contractor". */
+export interface OrganisationOption {
+ /** `organisation.id` (uuid). */
+ id: string;
+ name: string;
+ /** True when this organisation already delivers something on the project. */
+ alreadyAssigned: boolean;
+}
+
+/** Everything the permissions table renders. */
+export interface ContractorsView {
+ contractors: ContractorGroup[];
+ workstreams: ProjectWorkstreamOption[];
+}
+
+/**
+ * Every `project_workstream_contractor` row on a project, flattened with the
+ * organisation, the workstream name and the count of work orders issued
+ * against it.
+ *
+ * The work-order count is `count(distinct)` over a left join: the count is
+ * load-bearing (it is what refuses a removal), so it must not be inflated by
+ * any other join fan-out.
+ */
+async function loadAssignmentRows(
+ projectId: bigint,
+ organisationId?: string,
+): Promise {
+ const rows = await db
+ .select({
+ id: projectWorkstreamContractor.id,
+ projectWorkstreamId: projectWorkstreamContractor.projectWorkstreamId,
+ workstreamName: workstream.name,
+ organisationId: projectWorkstreamContractor.organisationId,
+ organisationName: organisation.name,
+ updateStagesPermission: projectWorkstreamContractor.updateStagesPermission,
+ uploadDocumentsPermission:
+ projectWorkstreamContractor.uploadDocumentsPermission,
+ workOrders: sql`count(distinct ${workOrder.id})::int`,
+ })
+ .from(projectWorkstreamContractor)
+ .innerJoin(
+ projectWorkstream,
+ eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
+ )
+ .innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id))
+ .innerJoin(
+ organisation,
+ eq(projectWorkstreamContractor.organisationId, organisation.id),
+ )
+ .leftJoin(
+ workOrder,
+ eq(workOrder.projectWorkstreamContractorId, projectWorkstreamContractor.id),
+ )
+ .where(
+ organisationId
+ ? and(
+ eq(projectWorkstream.projectId, projectId),
+ eq(projectWorkstreamContractor.organisationId, organisationId),
+ )
+ : eq(projectWorkstream.projectId, projectId),
+ )
+ .groupBy(
+ projectWorkstreamContractor.id,
+ workstream.name,
+ organisation.name,
+ );
+
+ return rows.map((row) => ({
+ id: row.id.toString(),
+ projectWorkstreamId: row.projectWorkstreamId.toString(),
+ workstreamName: row.workstreamName,
+ organisationId: row.organisationId,
+ // `organisation.name` is nullable in the schema; a nameless organisation
+ // still has to render as something a user can point at.
+ organisationName: row.organisationName ?? "Unnamed organisation",
+ updateStagesPermission: row.updateStagesPermission,
+ uploadDocumentsPermission: row.uploadDocumentsPermission,
+ workOrders: row.workOrders,
+ }));
+}
+
+/** The project's selected workstreams, with how many contractors each has. */
+export async function listProjectWorkstreams(
+ projectId: bigint,
+): Promise {
+ const rows = await db
+ .select({
+ projectWorkstreamId: projectWorkstream.id,
+ name: workstream.name,
+ contractorCount: sql`count(distinct ${projectWorkstreamContractor.id})::int`,
+ })
+ .from(projectWorkstream)
+ .innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id))
+ .leftJoin(
+ projectWorkstreamContractor,
+ eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
+ )
+ .where(eq(projectWorkstream.projectId, projectId))
+ .groupBy(projectWorkstream.id, workstream.name)
+ .orderBy(workstream.name);
+
+ return rows.map((row) => ({
+ projectWorkstreamId: row.projectWorkstreamId.toString(),
+ name: row.name,
+ contractorCount: row.contractorCount,
+ }));
+}
+
+/** The whole permissions table for one project, in one place. */
+export async function loadContractorsView(
+ projectId: bigint,
+): Promise {
+ const [rows, workstreams] = await Promise.all([
+ loadAssignmentRows(projectId),
+ listProjectWorkstreams(projectId),
+ ]);
+ return { contractors: groupByContractor(rows), workstreams };
+}
+
+/** One contractor's assignments on the project. Empty when it has none. */
+export async function findContractorAssignments(
+ projectId: bigint,
+ organisationId: string,
+): Promise {
+ const rows = await loadAssignmentRows(projectId, organisationId);
+ return groupByContractor(rows)[0]?.assignments ?? [];
+}
+
+/**
+ * Search existing organisations by name for the "Add contractor" picker.
+ *
+ * Creating organisations is **out of scope** for this flow (the "invite
+ * company to Ara" flow owns it), so this only ever reads. Organisations with
+ * no name are skipped: they cannot be told apart in a picker, so offering them
+ * would be offering a choice the user cannot make.
+ */
+export async function searchOrganisations(
+ projectId: bigint,
+ query: string,
+ limit = 20,
+): Promise {
+ // `%` and `_` are LIKE wildcards: a user typing them means the characters,
+ // not "match anything", so they are escaped rather than honoured.
+ const trimmed = query.trim().slice(0, 100).replace(/[\\%_]/g, "\\$&");
+
+ const [rows, assigned] = await Promise.all([
+ db
+ .select({ id: organisation.id, name: organisation.name })
+ .from(organisation)
+ .where(
+ trimmed
+ ? and(
+ isNotNull(organisation.name),
+ ne(organisation.name, ""),
+ ilike(organisation.name, `%${trimmed}%`),
+ )
+ : and(isNotNull(organisation.name), ne(organisation.name, "")),
+ )
+ .orderBy(organisation.name)
+ .limit(limit),
+ db
+ .selectDistinct({
+ organisationId: projectWorkstreamContractor.organisationId,
+ })
+ .from(projectWorkstreamContractor)
+ .innerJoin(
+ projectWorkstream,
+ eq(
+ projectWorkstreamContractor.projectWorkstreamId,
+ projectWorkstream.id,
+ ),
+ )
+ .where(eq(projectWorkstream.projectId, projectId)),
+ ]);
+
+ const assignedIds = new Set(assigned.map((row) => row.organisationId));
+
+ return rows.map((row) => ({
+ id: row.id,
+ name: row.name ?? "Unnamed organisation",
+ alreadyAssigned: assignedIds.has(row.id),
+ }));
+}
+
+/** True when `organisationId` is a real organisation. */
+export async function organisationExists(
+ organisationId: string,
+): Promise {
+ const [found] = await db
+ .select({ id: organisation.id })
+ .from(organisation)
+ .where(eq(organisation.id, organisationId))
+ .limit(1);
+ return Boolean(found);
+}
+
+/**
+ * How many contractors *other than* `organisationId` are assigned to each of
+ * `projectWorkstreamIds` — the "would this workstream be left with none?"
+ * fact `decideRemoval` judges on. Workstreams with no other contractor are
+ * absent from the record, which that function reads as zero.
+ */
+export async function countOtherContractors(
+ projectWorkstreamIds: bigint[],
+ organisationId: string,
+): Promise> {
+ if (projectWorkstreamIds.length === 0) return {};
+
+ const rows = await db
+ .select({
+ projectWorkstreamId: projectWorkstreamContractor.projectWorkstreamId,
+ contractors: sql`count(distinct ${projectWorkstreamContractor.organisationId})::int`,
+ })
+ .from(projectWorkstreamContractor)
+ .where(
+ and(
+ inArray(
+ projectWorkstreamContractor.projectWorkstreamId,
+ projectWorkstreamIds,
+ ),
+ ne(projectWorkstreamContractor.organisationId, organisationId),
+ ),
+ )
+ .groupBy(projectWorkstreamContractor.projectWorkstreamId);
+
+ return Object.fromEntries(
+ rows.map((row) => [row.projectWorkstreamId.toString(), row.contractors]),
+ );
+}
+
+/**
+ * One workstream and the permissions it should carry — the unit the grid
+ * edits. Permissions travel per workstream, never as one pair for the whole
+ * contractor (ADR-0022).
+ */
+export interface DesiredAssignment {
+ projectWorkstreamId: bigint;
+ updateStagesPermission: boolean;
+ uploadDocumentsPermission: boolean;
+}
+
+/** `"1-0"` — the flag pair, so rows sharing one can be updated together. */
+function flagKey(desired: DesiredAssignment): string {
+ return `${desired.updateStagesPermission ? 1 : 0}-${
+ desired.uploadDocumentsPermission ? 1 : 0
+ }`;
+}
+
+/**
+ * Write one contractor's assignments on a project.
+ *
+ * `mode` decides what happens to the workstreams the request did *not* name:
+ * - `"merge"` (POST, add contractor) leaves them alone.
+ * - `"replace"` (PATCH, edit contractor) deletes them — the caller must have
+ * run `decideRemoval` over exactly those rows first.
+ *
+ * `project_workstream_contractor` carries no unique index on
+ * (project_workstream_id, organisation_id), so the read-then-write runs inside
+ * a transaction — the narrowest guard available without a migration, matching
+ * `selectWorkstream` in the workstream step.
+ *
+ * Updates are grouped by flag pair rather than issued per row: there are only
+ * four possible pairs, so a contractor on twenty workstreams still costs at
+ * most four UPDATEs.
+ *
+ * `assigned_at` is left to its column default, so a re-permissioned assignment
+ * keeps the timestamp it was first made at.
+ */
+export async function writeContractorAssignments(
+ projectId: bigint,
+ organisationId: string,
+ desired: DesiredAssignment[],
+ mode: "merge" | "replace",
+): Promise<{ created: number; updated: number; removed: number }> {
+ return db.transaction(async (tx) => {
+ // Scoped to this project, not merely to the organisation: the same
+ // contractor may deliver workstreams on other projects, and a replace
+ // must never reach them.
+ const existing = await tx
+ .select({
+ id: projectWorkstreamContractor.id,
+ projectWorkstreamId: projectWorkstreamContractor.projectWorkstreamId,
+ })
+ .from(projectWorkstreamContractor)
+ .innerJoin(
+ projectWorkstream,
+ eq(
+ projectWorkstreamContractor.projectWorkstreamId,
+ projectWorkstream.id,
+ ),
+ )
+ .where(
+ and(
+ eq(projectWorkstream.projectId, projectId),
+ eq(projectWorkstreamContractor.organisationId, organisationId),
+ ),
+ );
+
+ const wanted = new Map(
+ desired.map((row) => [row.projectWorkstreamId.toString(), row]),
+ );
+ const held = new Map(
+ existing.map((row) => [row.projectWorkstreamId.toString(), row.id]),
+ );
+
+ const toCreate = desired.filter(
+ (row) => !held.has(row.projectWorkstreamId.toString()),
+ );
+
+ // Existing rows the request still wants, bucketed by the flag pair they
+ // should end up with.
+ const updateBuckets = new Map<
+ string,
+ { flags: DesiredAssignment; ids: bigint[] }
+ >();
+ let updated = 0;
+ for (const row of existing) {
+ const target = wanted.get(row.projectWorkstreamId.toString());
+ if (!target) continue;
+ const key = flagKey(target);
+ const bucket = updateBuckets.get(key) ?? { flags: target, ids: [] };
+ bucket.ids.push(row.id);
+ updateBuckets.set(key, bucket);
+ updated += 1;
+ }
+
+ if (toCreate.length > 0) {
+ await tx.insert(projectWorkstreamContractor).values(
+ toCreate.map((row) => ({
+ projectWorkstreamId: row.projectWorkstreamId,
+ organisationId,
+ updateStagesPermission: row.updateStagesPermission,
+ uploadDocumentsPermission: row.uploadDocumentsPermission,
+ })),
+ );
+ }
+
+ for (const { flags, ids } of updateBuckets.values()) {
+ await tx
+ .update(projectWorkstreamContractor)
+ .set({
+ updateStagesPermission: flags.updateStagesPermission,
+ uploadDocumentsPermission: flags.uploadDocumentsPermission,
+ })
+ .where(inArray(projectWorkstreamContractor.id, ids));
+ }
+
+ let removed = 0;
+ if (mode === "replace") {
+ const stale = existing
+ .filter((row) => !wanted.has(row.projectWorkstreamId.toString()))
+ .map((row) => row.id);
+
+ if (stale.length > 0) {
+ await tx
+ .delete(projectWorkstreamContractor)
+ .where(inArray(projectWorkstreamContractor.id, stale));
+ removed = stale.length;
+ }
+ }
+
+ return { created: toCreate.length, updated, removed };
+ });
+}
+
+/** Delete specific assignments by id. Callers run `decideRemoval` first. */
+export async function deleteAssignments(ids: bigint[]): Promise {
+ if (ids.length === 0) return 0;
+ await db
+ .delete(projectWorkstreamContractor)
+ .where(inArray(projectWorkstreamContractor.id, ids));
+ return ids.length;
+}
diff --git a/src/app/api/projects/[projectId]/contractors/route.test.ts b/src/app/api/projects/[projectId]/contractors/route.test.ts
new file mode 100644
index 00000000..743a1ea8
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/route.test.ts
@@ -0,0 +1,355 @@
+/**
+ * GET / POST `/api/projects/[projectId]/contractors` (issue #413).
+ *
+ * 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,
+ mockLoadContractorsView,
+ mockListProjectWorkstreams,
+ mockOrganisationExists,
+ mockWriteContractorAssignments,
+} = vi.hoisted(() => ({
+ mockGetServerSession: vi.fn(),
+ mockLoadAuthzUser: vi.fn(),
+ mockLoadProjectAuthzFacts: vi.fn(),
+ mockLoadContractorsView: vi.fn(),
+ mockListProjectWorkstreams: vi.fn(),
+ mockOrganisationExists: vi.fn(),
+ mockWriteContractorAssignments: 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", () => ({
+ loadContractorsView: mockLoadContractorsView,
+ listProjectWorkstreams: mockListProjectWorkstreams,
+ organisationExists: mockOrganisationExists,
+ writeContractorAssignments: mockWriteContractorAssignments,
+}));
+
+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 APEX = "44444444-4444-4444-4444-444444444444";
+
+const WORKSTREAMS = [
+ { projectWorkstreamId: "10", name: "Windows", contractorCount: 1 },
+ { projectWorkstreamId: "11", name: "Roofs", contractorCount: 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 = { params: Promise.resolve({ projectId: "5" }) };
+
+function postRequest(body: unknown) {
+ return new NextRequest("http://test/api/projects/5/contractors", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+}
+
+function getRequest() {
+ return new NextRequest("http://test/api/projects/5/contractors");
+}
+
+/** One grid row: a workstream and the permissions it should carry. */
+function ws(
+ projectWorkstreamId: string,
+ updateStagesPermission = false,
+ uploadDocumentsPermission = false,
+) {
+ return {
+ projectWorkstreamId,
+ updateStagesPermission,
+ uploadDocumentsPermission,
+ };
+}
+
+const VALID_BODY = {
+ organisationId: APEX,
+ assignments: [ws("10", true, false), ws("11", false, true)],
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockLoadContractorsView.mockResolvedValue({
+ contractors: [],
+ workstreams: WORKSTREAMS,
+ });
+ mockListProjectWorkstreams.mockResolvedValue(WORKSTREAMS);
+ mockOrganisationExists.mockResolvedValue(true);
+ mockWriteContractorAssignments.mockResolvedValue({
+ created: 2,
+ updated: 0,
+ removed: 0,
+ });
+});
+
+describe("GET /api/projects/[projectId]/contractors", () => {
+ it("returns the table with canManage for the client organisation", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await GET(getRequest(), params);
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({
+ contractors: [],
+ workstreams: WORKSTREAMS,
+ canManage: true,
+ });
+ });
+
+ it("lets an assigned contractor read the table, but not manage it", async () => {
+ signedInAs([CONTRACTOR_ORG]);
+ projectExists();
+
+ const res = await GET(getRequest(), params);
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({ canManage: false });
+ });
+
+ it("404s a project the caller has no standing on", async () => {
+ signedInAs([OTHER_ORG]);
+ projectExists();
+
+ const res = await GET(getRequest(), params);
+
+ expect(res.status).toBe(404);
+ expect(mockLoadContractorsView).not.toHaveBeenCalled();
+ });
+
+ it("401s without a session", async () => {
+ mockGetServerSession.mockResolvedValue(null);
+
+ const res = await GET(getRequest(), params);
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("POST /api/projects/[projectId]/contractors", () => {
+ it("assigns the contractor to every named workstream", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await POST(postRequest(VALID_BODY), params);
+
+ expect(res.status).toBe(201);
+ expect(mockWriteContractorAssignments).toHaveBeenCalledWith(
+ 5n,
+ APEX,
+ [
+ {
+ projectWorkstreamId: 10n,
+ updateStagesPermission: true,
+ uploadDocumentsPermission: false,
+ },
+ {
+ projectWorkstreamId: 11n,
+ updateStagesPermission: false,
+ uploadDocumentsPermission: true,
+ },
+ ],
+ "merge",
+ );
+ });
+
+ it("returns 200 when the assignment already existed and was re-permissioned", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockWriteContractorAssignments.mockResolvedValue({
+ created: 0,
+ updated: 2,
+ removed: 0,
+ });
+
+ const res = await POST(postRequest(VALID_BODY), params);
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({ updated: 2 });
+ });
+
+ it("defaults both permissions to off when the body omits them", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ await POST(
+ postRequest({
+ organisationId: APEX,
+ assignments: [{ projectWorkstreamId: "10" }],
+ }),
+ params,
+ );
+
+ expect(mockWriteContractorAssignments).toHaveBeenCalledWith(
+ 5n,
+ APEX,
+ [
+ {
+ projectWorkstreamId: 10n,
+ updateStagesPermission: false,
+ uploadDocumentsPermission: false,
+ },
+ ],
+ "merge",
+ );
+ });
+
+ it("403s a contractor trying to assign another contractor", async () => {
+ signedInAs([CONTRACTOR_ORG]);
+ projectExists();
+
+ const res = await POST(postRequest(VALID_BODY), params);
+
+ expect(res.status).toBe(403);
+ expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
+ });
+
+ it("rejects a body with no workstreams", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await POST(
+ postRequest({ organisationId: APEX, assignments: [] }),
+ params,
+ );
+
+ expect(res.status).toBe(400);
+ await expect(res.json()).resolves.toEqual({ error: "Invalid body" });
+ expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
+ });
+
+ it("rejects a workstream named twice — two answers to one question", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await POST(
+ postRequest({
+ organisationId: APEX,
+ assignments: [ws("10", true, true), ws("10", false, false)],
+ }),
+ params,
+ );
+
+ expect(res.status).toBe(400);
+ expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
+ });
+
+ it("carries different permissions per workstream through untouched", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ await POST(
+ postRequest({
+ organisationId: APEX,
+ assignments: [ws("10", true, false), ws("11", false, false)],
+ }),
+ params,
+ );
+
+ const [, , assignments] = mockWriteContractorAssignments.mock.calls[0];
+ expect(assignments).toEqual([
+ {
+ projectWorkstreamId: 10n,
+ updateStagesPermission: true,
+ uploadDocumentsPermission: false,
+ },
+ {
+ projectWorkstreamId: 11n,
+ updateStagesPermission: false,
+ uploadDocumentsPermission: false,
+ },
+ ]);
+ });
+
+ it("rejects an organisationId that is not a uuid", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await POST(
+ postRequest({ organisationId: "apex", assignments: [ws("10")] }),
+ params,
+ );
+
+ expect(res.status).toBe(400);
+ });
+
+ it("404s an organisation that does not exist", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockOrganisationExists.mockResolvedValue(false);
+
+ const res = await POST(postRequest(VALID_BODY), params);
+
+ expect(res.status).toBe(404);
+ await expect(res.json()).resolves.toEqual({
+ error: "Organisation not found",
+ });
+ expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
+ });
+
+ it("404s a workstream belonging to a different project", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+
+ const res = await POST(
+ postRequest({ ...VALID_BODY, assignments: [ws("10"), ws("999")] }),
+ params,
+ );
+
+ expect(res.status).toBe(404);
+ await expect(res.json()).resolves.toEqual({
+ error: "Workstream not found on this project",
+ });
+ expect(mockWriteContractorAssignments).not.toHaveBeenCalled();
+ });
+
+ it("500s, without leaking the error, when the write fails", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ mockWriteContractorAssignments.mockRejectedValue(new Error("pg exploded"));
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ const res = await POST(postRequest(VALID_BODY), params);
+
+ expect(res.status).toBe(500);
+ await expect(res.json()).resolves.toEqual({
+ error: "Couldn't assign the contractor",
+ });
+ consoleError.mockRestore();
+ });
+});
diff --git a/src/app/api/projects/[projectId]/contractors/route.ts b/src/app/api/projects/[projectId]/contractors/route.ts
new file mode 100644
index 00000000..00f62520
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/route.ts
@@ -0,0 +1,106 @@
+/**
+ * `/api/projects/[projectId]/contractors` (issue #413).
+ *
+ * GET — the permissions table: every contractor on the project, the
+ * workstreams each delivers, and their two permission flags.
+ * POST — add a contractor: one `project_workstream_contractor` row per chosen
+ * workstream.
+ *
+ * Editing and removing an existing contractor live on
+ * `[organisationId]/route.ts`, because their removal rules need to name a
+ * single contractor.
+ */
+import { NextRequest, NextResponse } from "next/server";
+import { authorizeContractorRequest } from "./authorize";
+import {
+ addContractorBodySchema,
+ type AddContractorBody,
+} from "./validation";
+import {
+ listProjectWorkstreams,
+ loadContractorsView,
+ organisationExists,
+ writeContractorAssignments,
+} from "./queries";
+
+type Params = { params: Promise<{ projectId: string }> };
+
+/** GET — the table's data, re-read from the database on every request. */
+export async function GET(_req: NextRequest, props: Params) {
+ const { projectId } = await props.params;
+ const auth = await authorizeContractorRequest(projectId, "view");
+ if (!auth.ok) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status });
+ }
+
+ const view = await loadContractorsView(auth.projectId);
+ return NextResponse.json({ ...view, canManage: auth.canManage });
+}
+
+/**
+ * POST — assign a contractor to one or more of the project's workstreams, each
+ * with its own permissions.
+ *
+ * Additive: workstreams the body does not name are left alone, so adding a
+ * second contractor never displaces the first (the schema allows several per
+ * workstream, and so does this). Re-posting a workstream the contractor
+ * already holds re-permissions it rather than duplicating the row.
+ */
+export async function POST(req: NextRequest, props: Params) {
+ const { projectId } = await props.params;
+ const auth = await authorizeContractorRequest(projectId, "manage");
+ if (!auth.ok) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status });
+ }
+
+ let body: AddContractorBody;
+ try {
+ body = addContractorBodySchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ if (!(await organisationExists(body.organisationId))) {
+ return NextResponse.json(
+ { error: "Organisation not found" },
+ { status: 404 },
+ );
+ }
+
+ // Every named workstream must belong to *this* project — otherwise a caller
+ // who may manage one project could assign contractors on another.
+ const owned = new Set(
+ (await listProjectWorkstreams(auth.projectId)).map(
+ (w) => w.projectWorkstreamId,
+ ),
+ );
+ if (body.assignments.some((a) => !owned.has(a.projectWorkstreamId))) {
+ return NextResponse.json(
+ { error: "Workstream not found on this project" },
+ { status: 404 },
+ );
+ }
+
+ try {
+ const result = await writeContractorAssignments(
+ auth.projectId,
+ body.organisationId,
+ body.assignments.map((a) => ({
+ projectWorkstreamId: BigInt(a.projectWorkstreamId),
+ updateStagesPermission: a.updateStagesPermission,
+ uploadDocumentsPermission: a.uploadDocumentsPermission,
+ })),
+ "merge",
+ );
+ return NextResponse.json(
+ { organisationId: body.organisationId, ...result },
+ { status: result.created > 0 ? 201 : 200 },
+ );
+ } catch (err) {
+ console.error("POST /api/projects/[projectId]/contractors failed:", err);
+ return NextResponse.json(
+ { error: "Couldn't assign the contractor" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/projects/[projectId]/contractors/validation.ts b/src/app/api/projects/[projectId]/contractors/validation.ts
new file mode 100644
index 00000000..7520af2c
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contractors/validation.ts
@@ -0,0 +1,52 @@
+/**
+ * Request body shapes for the contractor endpoints (issue #413).
+ *
+ * They live outside the two `route.ts` files because both need the assignment
+ * body and an App Router route module may only export HTTP handlers and the
+ * route-config options — an exported schema there is a build error.
+ */
+import { z } from "zod";
+
+/**
+ * One (workstream, permissions) triple — the unit the grid edits and the unit
+ * `project_workstream_contractor` stores.
+ *
+ * `project_workstream.id` arrives as a string: it is bigint in the database and
+ * does not survive JSON. Both flags default to false, matching the column
+ * defaults — an assignment grants nothing unless asked.
+ */
+export const assignmentSchema = z.object({
+ projectWorkstreamId: z
+ .string()
+ .regex(/^\d+$/, "workstream ids must be numeric"),
+ updateStagesPermission: z.boolean().default(false),
+ uploadDocumentsPermission: z.boolean().default(false),
+});
+
+/**
+ * The assignment set a write asks for — permissions per workstream, never one
+ * pair applied to all of them (ADR-0022).
+ *
+ * A workstream may appear only once: two entries for the same workstream carry
+ * two answers to the same question, and picking one silently is worse than
+ * refusing.
+ */
+export const assignmentBodySchema = z.object({
+ assignments: z
+ .array(assignmentSchema)
+ .min(1, "Choose at least one workstream")
+ .refine(
+ (rows) =>
+ new Set(rows.map((r) => r.projectWorkstreamId)).size === rows.length,
+ { message: "Each workstream may appear only once" },
+ ),
+});
+
+/** Adding a contractor also names which organisation is being assigned. */
+export const addContractorBodySchema = assignmentBodySchema.extend({
+ organisationId: z.string().uuid("organisationId must be a uuid"),
+});
+
+export type AssignmentInput = z.infer;
+export type AssignmentBody = z.infer;
+export type AddContractorBody = z.infer;
diff --git a/src/app/api/projects/[projectId]/import/parse/route.ts b/src/app/api/projects/[projectId]/import/parse/route.ts
new file mode 100644
index 00000000..0f487888
--- /dev/null
+++ b/src/app/api/projects/[projectId]/import/parse/route.ts
@@ -0,0 +1,99 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { canManageProject } from "@/lib/projects/authz";
+import {
+ loadAuthzUser,
+ loadProjectAuthzFacts,
+} from "@/app/repositories/projects/authzRepository";
+import {
+ hasAcceptedImportExtension,
+ MAX_IMPORT_FILE_BYTES,
+ parseImportFile,
+} from "@/lib/projects/import/parse";
+
+type Params = { params: Promise<{ projectId: string }> };
+
+/**
+ * POST — parse an uploaded work-order import file (issue #414).
+ *
+ * Takes `multipart/form-data` with a single `file` field and returns the
+ * headers, every data row, a row count and a first-N preview. It writes
+ * nothing: the import is not persisted until commit (#417).
+ *
+ * The rows come back in full, not just the preview, because the parsed file is
+ * held client-side across the mapping and validation steps and POSTed once at
+ * commit — see the commit message for why that beat staging the file in S3.
+ */
+export async function POST(req: NextRequest, props: Params) {
+ const { projectId } = await props.params;
+
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.dbId) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ if (!/^\d+$/.test(projectId)) {
+ return NextResponse.json({ error: "Invalid project id" }, { status: 400 });
+ }
+
+ const [user, project] = await Promise.all([
+ loadAuthzUser(BigInt(session.user.dbId)),
+ loadProjectAuthzFacts(BigInt(projectId)),
+ ]);
+ // A project the caller cannot see must 404, not 403 — its existence is not
+ // theirs to learn (see `@/lib/projects/authz`).
+ if (!user || !project) {
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
+ }
+ // Importing is managing: internal and client-org only, never a contractor.
+ if (!canManageProject(user, project)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ let form: FormData;
+ try {
+ form = await req.formData();
+ } catch {
+ return NextResponse.json(
+ { error: "Expected a file upload" },
+ { status: 400 },
+ );
+ }
+
+ const file = form.get("file");
+ if (!(file instanceof File)) {
+ return NextResponse.json({ error: "No file was uploaded" }, { status: 400 });
+ }
+ if (!hasAcceptedImportExtension(file.name)) {
+ return NextResponse.json(
+ { error: "That file type isn't supported — upload a .csv, .xlsx or .xls file." },
+ { status: 400 },
+ );
+ }
+ // Re-checked here even though the drop zone checks it too: the client-side
+ // limit is a courtesy, this one is the rule.
+ if (file.size > MAX_IMPORT_FILE_BYTES) {
+ return NextResponse.json(
+ {
+ error: `That file is larger than ${MAX_IMPORT_FILE_BYTES / (1024 * 1024)}MB — split it into smaller files.`,
+ },
+ { status: 413 },
+ );
+ }
+ if (file.size === 0) {
+ return NextResponse.json({ error: "That file is empty." }, { status: 400 });
+ }
+
+ const result = parseImportFile(new Uint8Array(await file.arrayBuffer()));
+ if (!result.ok) {
+ return NextResponse.json({ error: result.error }, { status: 400 });
+ }
+
+ return NextResponse.json({
+ filename: file.name,
+ headers: result.file.headers,
+ rows: result.file.rows,
+ rowCount: result.file.rowCount,
+ preview: result.file.preview,
+ });
+}
diff --git a/src/app/api/projects/[projectId]/import/template/route.ts b/src/app/api/projects/[projectId]/import/template/route.ts
new file mode 100644
index 00000000..3371555a
--- /dev/null
+++ b/src/app/api/projects/[projectId]/import/template/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { canManageProject } from "@/lib/projects/authz";
+import {
+ loadAuthzUser,
+ loadProjectAuthzFacts,
+} from "@/app/repositories/projects/authzRepository";
+import { buildImportTemplateCsv } from "@/lib/projects/import/parse";
+
+type Params = { params: Promise<{ projectId: string }> };
+
+/**
+ * GET — the "Download template" artefact: a CSV carrying the expected headings
+ * plus a worked example (issue #414).
+ *
+ * Served from a route handler rather than built in the browser so the template
+ * and the parser read their column list from the same constant — a heading
+ * renamed in one place cannot drift from the other.
+ *
+ * Gated behind the same permission as the import itself: the template is not
+ * secret, but there is no reason for it to be the one Projects endpoint a
+ * contractor can reach.
+ */
+export async function GET(_req: NextRequest, props: Params) {
+ const { projectId } = await props.params;
+
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.dbId) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ if (!/^\d+$/.test(projectId)) {
+ return NextResponse.json({ error: "Invalid project id" }, { status: 400 });
+ }
+
+ const [user, project] = await Promise.all([
+ loadAuthzUser(BigInt(session.user.dbId)),
+ loadProjectAuthzFacts(BigInt(projectId)),
+ ]);
+ if (!user || !project) {
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
+ }
+ if (!canManageProject(user, project)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ // BOM so Excel opens the CSV as UTF-8 rather than guessing a codepage.
+ return new NextResponse(`${buildImportTemplateCsv()}`, {
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition":
+ 'attachment; filename="work-order-import-template.csv"',
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts
new file mode 100644
index 00000000..9762018f
--- /dev/null
+++ b/src/app/api/projects/[projectId]/route.ts
@@ -0,0 +1,99 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { canManageProject, canViewProject } from "@/lib/projects/authz";
+import {
+ projectFactsAfterUpdate,
+ updateProjectSchema,
+} from "@/lib/projects/updateProject";
+import { parseProjectId } from "@/app/projects/guards";
+import {
+ loadAuthzUserByEmail,
+ loadProjectAuthzFacts,
+} from "@/app/repositories/projects/authzRepository";
+import { projectTypeExists } from "@/app/repositories/projects/projectsRepository";
+import { updateProjectDetails } from "@/app/repositories/projects/projectSettingsRepository";
+
+/**
+ * PATCH /api/projects/[projectId] — edit a project's details from the settings
+ * hub (issue #444).
+ *
+ * Authorization is the existing rule, asked twice:
+ *
+ * 1. **May this user manage this project?** — `canManageProject` against the
+ * stored facts. A user who cannot even *view* the project gets a 404 rather
+ * than a 403, because project existence must not leak outside its
+ * organisation; a contractor, who legitimately knows the project exists,
+ * gets an honest 403.
+ * 2. **Would they still manage it afterwards?** — `canManageProject` against
+ * `projectFactsAfterUpdate`. Un-ticking `domna_admin_access` revokes the
+ * `internal` role, so an internal non-member could otherwise save an edit
+ * that instantly locks them out of the project with no way back. This is
+ * the same guard `POST /api/projects` applies to creation, for the same
+ * reason — no separate "may this edit be made?" rule exists.
+ *
+ * The owning organisation is never read from the request body: it is not part
+ * of `updateProjectSchema`, and the update statement does not touch the column.
+ */
+export async function PATCH(
+ req: NextRequest,
+ props: { params: Promise<{ projectId: string }> },
+) {
+ const { projectId } = await props.params;
+
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const user = await loadAuthzUserByEmail(session.user.email);
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+
+ const id = parseProjectId(projectId);
+ if (id === null) {
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
+ }
+
+ const project = await loadProjectAuthzFacts(id);
+ if (!project || !canViewProject(user, project)) {
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
+ }
+ if (!canManageProject(user, project)) {
+ return NextResponse.json(
+ { error: "You cannot change this project's settings" },
+ { status: 403 },
+ );
+ }
+
+ const parsed = updateProjectSchema.safeParse(
+ await req.json().catch(() => null),
+ );
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Invalid body" },
+ { status: 400 },
+ );
+ }
+ const draft = parsed.data;
+
+ if (!canManageProject(user, projectFactsAfterUpdate(project, draft))) {
+ return NextResponse.json(
+ {
+ error:
+ "Removing Domna admin access would lock you out of this project. Ask a member of the client organisation to make that change.",
+ },
+ { status: 403 },
+ );
+ }
+
+ const projectTypeId = BigInt(draft.projectTypeId);
+ if (!(await projectTypeExists(projectTypeId))) {
+ return NextResponse.json({ error: "Unknown project type" }, { status: 400 });
+ }
+
+ await updateProjectDetails(id, { ...draft, projectTypeId });
+
+ return NextResponse.json({ id: id.toString() }, { status: 200 });
+}
diff --git a/src/app/api/projects/[projectId]/sharepoint/queries.ts b/src/app/api/projects/[projectId]/sharepoint/queries.ts
new file mode 100644
index 00000000..f20d5799
--- /dev/null
+++ b/src/app/api/projects/[projectId]/sharepoint/queries.ts
@@ -0,0 +1,49 @@
+/**
+ * Persistence for the two project-level SharePoint columns (issue #412).
+ *
+ * Deliberately narrow: it reads and writes `sharepoint_base_url` and
+ * `sharepoint_sub_folder_template` and touches no other column of `project`, so
+ * this screen can never disturb the details the settings hub owns (#444).
+ */
+import { db } from "@/app/db/db";
+import { eq } from "drizzle-orm";
+import { project } from "@/app/db/schema/projects/projects";
+import type {
+ SharepointSettings,
+ SharepointSettingsDraft,
+} from "@/lib/projects/sharepointSettings";
+
+/** The stored fields, or null when the id names no project row. */
+export async function loadSharepointSettings(
+ projectId: bigint,
+): Promise {
+ const [row] = await db
+ .select({
+ sharepointBaseUrl: project.sharepointBaseUrl,
+ sharepointSubFolderTemplate: project.sharepointSubFolderTemplate,
+ })
+ .from(project)
+ .where(eq(project.id, projectId))
+ .limit(1);
+ return row ?? null;
+}
+
+/**
+ * Write both fields.
+ *
+ * Absent values are written as explicit `null` rather than omitted, so clearing
+ * a field in the form actually clears the column — an omitted key would leave
+ * the old value in place, which reads back as the edit having silently failed.
+ */
+export async function updateSharepointSettings(
+ projectId: bigint,
+ draft: SharepointSettingsDraft,
+): Promise {
+ await db
+ .update(project)
+ .set({
+ sharepointBaseUrl: draft.sharepointBaseUrl ?? null,
+ sharepointSubFolderTemplate: draft.sharepointSubFolderTemplate ?? null,
+ })
+ .where(eq(project.id, projectId));
+}
diff --git a/src/app/api/projects/[projectId]/sharepoint/route.test.ts b/src/app/api/projects/[projectId]/sharepoint/route.test.ts
new file mode 100644
index 00000000..95590d1f
--- /dev/null
+++ b/src/app/api/projects/[projectId]/sharepoint/route.test.ts
@@ -0,0 +1,162 @@
+/**
+ * GET / PATCH `/api/projects/[projectId]/sharepoint` (issue #412).
+ *
+ * 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,
+ mockLoadSharepointSettings,
+ mockUpdateSharepointSettings,
+} = vi.hoisted(() => ({
+ mockGetServerSession: vi.fn(),
+ mockLoadAuthzUser: vi.fn(),
+ mockLoadProjectAuthzFacts: vi.fn(),
+ mockLoadSharepointSettings: vi.fn(),
+ mockUpdateSharepointSettings: 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", () => ({
+ loadSharepointSettings: mockLoadSharepointSettings,
+ updateSharepointSettings: mockUpdateSharepointSettings,
+}));
+
+import { GET, PATCH } from "./route";
+
+const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
+const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
+
+const STORED = {
+ sharepointBaseUrl: "https://example.sharepoint.com/sites/works",
+ sharepointSubFolderTemplate: "/[AssetRef]/[WORef]",
+};
+
+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 = () => ({ params: Promise.resolve({ projectId: "5" }) });
+const url = "http://localhost/api/projects/5/sharepoint";
+
+const patchRequest = (body: unknown) =>
+ new NextRequest(url, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ headers: { "content-type": "application/json" },
+ });
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockLoadSharepointSettings.mockResolvedValue(STORED);
+ mockUpdateSharepointSettings.mockResolvedValue(undefined);
+});
+
+describe("GET", () => {
+ it("401s without a session", async () => {
+ mockGetServerSession.mockResolvedValue(null);
+ const res = await GET(new NextRequest(url), params());
+ expect(res.status).toBe(401);
+ });
+
+ it("returns the stored fields", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ const res = await GET(new NextRequest(url), params());
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ settings: STORED, canManage: true });
+ });
+});
+
+describe("PATCH", () => {
+ it("403s for a contractor", async () => {
+ signedInAs([CONTRACTOR_ORG]);
+ projectExists();
+ const res = await PATCH(patchRequest(STORED), params());
+ expect(res.status).toBe(403);
+ expect(mockUpdateSharepointSettings).not.toHaveBeenCalled();
+ });
+
+ it("400s on a base URL that is not a full URL", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ const res = await PATCH(
+ patchRequest({ sharepointBaseUrl: "example.sharepoint.com/sites/works" }),
+ params(),
+ );
+ expect(res.status).toBe(400);
+ expect(await res.json()).toEqual({
+ error: "Enter a full URL, including https://",
+ });
+ expect(mockUpdateSharepointSettings).not.toHaveBeenCalled();
+ });
+
+ it("stores both fields", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ const res = await PATCH(patchRequest(STORED), params());
+ expect(res.status).toBe(200);
+ expect(mockUpdateSharepointSettings).toHaveBeenCalledWith(5n, {
+ sharepointBaseUrl: STORED.sharepointBaseUrl,
+ sharepointSubFolderTemplate: STORED.sharepointSubFolderTemplate,
+ });
+ });
+
+ it("clears a field the user emptied", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ const res = await PATCH(
+ patchRequest({ sharepointBaseUrl: "", sharepointSubFolderTemplate: "" }),
+ params(),
+ );
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ settings: {
+ sharepointBaseUrl: null,
+ sharepointSubFolderTemplate: null,
+ },
+ });
+ expect(mockUpdateSharepointSettings).toHaveBeenCalledWith(5n, {
+ sharepointBaseUrl: undefined,
+ sharepointSubFolderTemplate: undefined,
+ });
+ });
+
+ it("500s without leaking the driver error", async () => {
+ signedInAs([CLIENT_ORG]);
+ projectExists();
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ mockUpdateSharepointSettings.mockRejectedValue(new Error("connection reset"));
+ const res = await PATCH(patchRequest(STORED), params());
+ expect(res.status).toBe(500);
+ expect(await res.json()).toEqual({
+ error: "Couldn't save the SharePoint settings",
+ });
+ consoleError.mockRestore();
+ });
+});
diff --git a/src/app/api/projects/[projectId]/sharepoint/route.ts b/src/app/api/projects/[projectId]/sharepoint/route.ts
new file mode 100644
index 00000000..d51d1b29
--- /dev/null
+++ b/src/app/api/projects/[projectId]/sharepoint/route.ts
@@ -0,0 +1,71 @@
+/**
+ * `PATCH /api/projects/[projectId]/sharepoint` (issue #412).
+ *
+ * Stores the two project-level SharePoint fields the evidence-checklist screen
+ * collects. See `@/lib/projects/sharepointSettings` for why they are edited
+ * here rather than through the settings hub's details form, and for the fact
+ * that v1 **stores** them and syncs nothing (#429 owns sync).
+ *
+ * Authorization reuses `authorizeWorkstreamRequest` (#410) rather than restating
+ * the rule: despite the name, it is the project-level guard every endpoint under
+ * `/api/projects/[projectId]/…` in this module asks — 401 unauthenticated, 404
+ * for a project the caller cannot see (existence must not leak outside its
+ * organisation) and 403 for a viewer who may not manage it.
+ */
+import { NextRequest, NextResponse } from "next/server";
+import { authorizeWorkstreamRequest } from "../workstreams/authorize";
+import { sharepointSettingsSchema } from "@/lib/projects/sharepointSettings";
+import { loadSharepointSettings, updateSharepointSettings } from "./queries";
+
+type Params = { params: Promise<{ projectId: string }> };
+
+/** GET — the stored fields, for a client that wants to re-read them. */
+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 settings = await loadSharepointSettings(auth.projectId);
+ if (!settings) {
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
+ }
+ return NextResponse.json({ settings, canManage: auth.canManage });
+}
+
+export async function PATCH(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 });
+ }
+
+ const parsed = sharepointSettingsSchema.safeParse(
+ await req.json().catch(() => null),
+ );
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Invalid body" },
+ { status: 400 },
+ );
+ }
+
+ try {
+ await updateSharepointSettings(auth.projectId, parsed.data);
+ } catch (err) {
+ console.error("PATCH /api/projects/[projectId]/sharepoint failed:", err);
+ return NextResponse.json(
+ { error: "Couldn't save the SharePoint settings" },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json({
+ settings: {
+ sharepointBaseUrl: parsed.data.sharepointBaseUrl ?? null,
+ sharepointSubFolderTemplate:
+ parsed.data.sharepointSubFolderTemplate ?? null,
+ },
+ });
+}
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..f9e63775
--- /dev/null
+++ b/src/app/api/projects/[projectId]/work-orders/filters.test.ts
@@ -0,0 +1,194 @@
+/**
+ * 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,
+ withWorkstreamFilter,
+ 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("withWorkstreamFilter", () => {
+ const STAGES = [
+ { id: "101", workstreamId: "10" },
+ { id: "201", workstreamId: "20" },
+ ];
+
+ it("drops a stage that belongs to a different workstream", () => {
+ const next = withWorkstreamFilter(
+ { ...NO_FILTERS, stageId: "101" },
+ STAGES,
+ "20",
+ );
+ expect(next).toMatchObject({ workstreamId: "20", stageId: null });
+ });
+
+ it("keeps a stage that belongs to the workstream being selected", () => {
+ const next = withWorkstreamFilter(
+ { ...NO_FILTERS, stageId: "101" },
+ STAGES,
+ "10",
+ );
+ expect(next).toMatchObject({ workstreamId: "10", stageId: "101" });
+ });
+
+ it("keeps the stage when the workstream filter is cleared", () => {
+ const next = withWorkstreamFilter(
+ { ...NO_FILTERS, stageId: "101" },
+ STAGES,
+ null,
+ );
+ expect(next).toMatchObject({ workstreamId: null, stageId: "101" });
+ });
+
+ it("leaves the other filters alone", () => {
+ const next = withWorkstreamFilter(
+ { ...NO_FILTERS, overdueOnly: true, missingEvidence: true },
+ STAGES,
+ "20",
+ );
+ expect(next).toMatchObject({ overdueOnly: true, missingEvidence: true });
+ });
+});
+
+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..afc8366d
--- /dev/null
+++ b/src/app/api/projects/[projectId]/work-orders/filters.ts
@@ -0,0 +1,243 @@
+/**
+ * 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 `