Merge pull request #464 from Hestia-Homes/main
Some checks failed
Test Suite / unit-tests (push) Has been cancelled

woo deploy!
This commit is contained in:
Daniel Roth 2026-07-27 11:54:57 +01:00 committed by GitHub
commit b896f22bf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
165 changed files with 93368 additions and 213 deletions

View file

@ -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 `<input type="date">`.** 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.

View file

@ -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

View file

@ -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();
}

View file

@ -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/<anything> 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);
});
});

View file

@ -0,0 +1,102 @@
# 19. Dates are entered and displayed as dd/mm/yyyy, not with `<input type="date">`
Date: 2026-07-22
## Status
Accepted
Numbering note: 00150017 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
`<input type="date">`, 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
`<input type="date">`.** 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 `<input type="date">` 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.

View file

@ -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: 00150017 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 0207) 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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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).

View file

@ -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.

60
package-lock.json generated
View file

@ -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": {

View file

@ -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",

View file

@ -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();
});
});

View file

@ -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<NextResponse | null> {
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 },
);
}
}

View file

@ -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> = {},
): 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> = {},
): 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" });
});
});

View file

@ -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<string, ContractorGroup>();
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<string, number>;
}
/**
* 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" };
}

View file

@ -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";

View file

@ -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);
});
});

View file

@ -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 });
}

View file

@ -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);
});
});

View file

@ -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<ContractorAssignmentRow[]> {
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<number>`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<ProjectWorkstreamOption[]> {
const rows = await db
.select({
projectWorkstreamId: projectWorkstream.id,
name: workstream.name,
contractorCount: sql<number>`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<ContractorsView> {
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<ContractorAssignment[]> {
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<OrganisationOption[]> {
// `%` 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<boolean> {
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<Record<string, number>> {
if (projectWorkstreamIds.length === 0) return {};
const rows = await db
.select({
projectWorkstreamId: projectWorkstreamContractor.projectWorkstreamId,
contractors: sql<number>`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<number> {
if (ids.length === 0) return 0;
await db
.delete(projectWorkstreamContractor)
.where(inArray(projectWorkstreamContractor.id, ids));
return ids.length;
}

View file

@ -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();
});
});

View file

@ -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 },
);
}
}

View file

@ -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<typeof assignmentSchema>;
export type AssignmentBody = z.infer<typeof assignmentBodySchema>;
export type AddContractorBody = z.infer<typeof addContractorBodySchema>;

View file

@ -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,
});
}

View file

@ -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",
},
});
}

View file

@ -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 });
}

View file

@ -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<SharepointSettings | null> {
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<void> {
await db
.update(project)
.set({
sharepointBaseUrl: draft.sharepointBaseUrl ?? null,
sharepointSubFolderTemplate: draft.sharepointSubFolderTemplate ?? null,
})
.where(eq(project.id, projectId));
}

View file

@ -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();
});
});

View file

@ -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,
},
});
}

View file

@ -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<AuthorizeResult> {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return { ok: false, error: "Unauthorised", status: 401 };
}
if (!/^\d+$/.test(projectIdParam)) {
return { ok: false, error: "Invalid project", status: 400 };
}
const projectId = BigInt(projectIdParam);
const [user, project] = await Promise.all([
loadAuthzUser(BigInt(session.user.dbId)),
loadProjectAuthzFacts(projectId),
]);
if (!user) return { ok: false, error: "Unauthorised", status: 401 };
if (!project) return { ok: false, error: "Project not found", status: 404 };
if (!canViewProject(user, project)) {
return { ok: false, error: "Project not found", status: 404 };
}
if (!canManageProject(user, project)) {
return { ok: false, error: "Forbidden", status: 403 };
}
return { ok: true, projectId };
}

View file

@ -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);
});
});

View file

@ -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 `<select>` value is a
* string anyway.
* - **Query** (`WorkOrderQuery`) is what the server acts on: ids parsed to
* `bigint`, ready for Drizzle. Parsing is the boundary where a junk id
* becomes a 400 rather than a database error.
*/
/** `project_workstream.id` / `project_workstream_stage.id` as a wire string. */
const NUMERIC_ID = /^\d+$/;
/** `organisation.id` is a uuid; anything else is not a contractor. */
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Rows per page when the caller does not ask for a size. */
export const DEFAULT_PAGE_SIZE = 50;
/**
* The most rows one request may ask for. A programme runs to thousands of work
* orders, so an unbounded `limit` is a way to ask the server to stream the
* whole table into one response.
*/
export const MAX_PAGE_SIZE = 200;
/**
* The choices the filter bar offers, all read live from the project's setup.
*
* Declared here rather than beside the query that loads it (`./queries`) so a
* client component can name the type without pulling the database client into
* its module graph.
*/
export interface WorkOrderFilterOptions {
/** Project Workstreams, named from the `workstream` reference data. */
workstreams: { id: string; name: string }[];
/** Every organisation assigned as contractor to any of the project's workstreams. */
contractors: { organisationId: string; name: string }[];
/** Every stage of every workstream, so the stage filter can narrow to one workstream. */
stages: { id: string; name: string; workstreamId: string; order: number }[];
}
/** The filter bar's state: what the user has narrowed the table to. */
export interface WorkOrderFilterSelection {
/** `project_workstream.id` — the Project Workstream, not the catalogue row. */
workstreamId: string | null;
/** `organisation.id` of the contractor. */
contractorOrganisationId: string | null;
/** `project_workstream_stage.id`. */
stageId: string | null;
/** Only work orders that are overdue by the shared derivation rule. */
overdueOnly: boolean;
/** Only work orders with at least one applicable required document missing. */
missingEvidence: boolean;
}
/** No filters at all — the table's opening state. */
export const NO_FILTERS: WorkOrderFilterSelection = {
workstreamId: null,
contractorOrganisationId: null,
stageId: null,
overdueOnly: false,
missingEvidence: false,
};
/**
* Change the workstream filter, dropping a stage filter that cannot survive it.
*
* A stage belongs to exactly one Project Workstream, so a selection that keeps
* "Windows · In progress" while narrowing to Roofs composes into a guaranteed
* empty table and the user has no way to tell that from "Roofs has no work
* orders in progress". Filters compose; a pair that cannot compose is dropped
* rather than shown.
*/
export function withWorkstreamFilter(
selection: WorkOrderFilterSelection,
stages: readonly { id: string; workstreamId: string }[],
workstreamId: string | null,
): WorkOrderFilterSelection {
const stage = stages.find((candidate) => candidate.id === selection.stageId);
const keepStage = workstreamId === null || stage?.workstreamId === workstreamId;
return {
...selection,
workstreamId,
stageId: keepStage ? selection.stageId : null,
};
}
/** True when nothing is narrowed, so the UI can label the empty state honestly. */
export function hasActiveFilters(selection: WorkOrderFilterSelection): boolean {
return (
selection.workstreamId !== null ||
selection.contractorOrganisationId !== null ||
selection.stageId !== null ||
selection.overdueOnly ||
selection.missingEvidence
);
}
/** The parsed, server-side form of one page request. */
export interface WorkOrderQuery {
workstreamId: bigint | null;
contractorOrganisationId: string | null;
stageId: bigint | null;
overdueOnly: boolean;
missingEvidence: boolean;
/**
* Keyset cursor: the `work_order.id` of the last row of the previous page.
* Null asks for the first page. Rows are ordered by `work_order.id`, so a
* cursor stays meaningful under any combination of filters the whole point
* of paginating by key rather than by offset.
*/
cursor: bigint | null;
limit: number;
}
/** The query a request with no parameters at all asks for. */
export const DEFAULT_QUERY: WorkOrderQuery = {
workstreamId: null,
contractorOrganisationId: null,
stageId: null,
overdueOnly: false,
missingEvidence: false,
cursor: null,
limit: DEFAULT_PAGE_SIZE,
};
export type ParsedQuery =
| { ok: true; query: WorkOrderQuery }
| { ok: false; error: string };
/**
* Parse a request's search params into a `WorkOrderQuery`.
*
* Every failure is reported rather than silently defaulted: a filter the server
* quietly dropped shows the user a table that does not match the controls above
* it, which is worse than an error. An *absent* parameter is not a failure it
* simply means "not filtered".
*/
export function parseWorkOrderQuery(params: URLSearchParams): ParsedQuery {
const workstreamId = parseId(params.get("workstreamId"));
if (workstreamId === INVALID) {
return { ok: false, error: "Invalid workstreamId" };
}
const stageId = parseId(params.get("stageId"));
if (stageId === INVALID) return { ok: false, error: "Invalid stageId" };
const cursor = parseId(params.get("cursor"));
if (cursor === INVALID) return { ok: false, error: "Invalid cursor" };
const contractor = params.get("contractorOrganisationId");
if (contractor !== null && !UUID.test(contractor)) {
return { ok: false, error: "Invalid contractorOrganisationId" };
}
const overdueOnly = parseBoolean(params.get("overdue"));
if (overdueOnly === INVALID) return { ok: false, error: "Invalid overdue" };
const missingEvidence = parseBoolean(params.get("missingEvidence"));
if (missingEvidence === INVALID) {
return { ok: false, error: "Invalid missingEvidence" };
}
const limit = parseLimit(params.get("limit"));
if (limit === INVALID) return { ok: false, error: "Invalid limit" };
return {
ok: true,
query: {
workstreamId,
contractorOrganisationId: contractor,
stageId,
overdueOnly,
missingEvidence,
cursor,
limit,
},
};
}
/**
* Build the search params for one page request the exact inverse of
* `parseWorkOrderQuery`, and the only place the client names a parameter.
*
* Falsey filters are omitted rather than serialised as `false`/empty, so the
* unfiltered first page has a stable, empty query string and therefore a stable
* React Query cache key.
*/
export function workOrderSearchParams(
selection: WorkOrderFilterSelection,
page: { cursor?: string | null; limit?: number } = {},
): URLSearchParams {
const params = new URLSearchParams();
if (selection.workstreamId) params.set("workstreamId", selection.workstreamId);
if (selection.contractorOrganisationId) {
params.set("contractorOrganisationId", selection.contractorOrganisationId);
}
if (selection.stageId) params.set("stageId", selection.stageId);
if (selection.overdueOnly) params.set("overdue", "true");
if (selection.missingEvidence) params.set("missingEvidence", "true");
if (page.cursor) params.set("cursor", page.cursor);
if (page.limit !== undefined) params.set("limit", String(page.limit));
return params;
}
/** Sentinel distinguishing "not supplied" (null) from "supplied but wrong". */
const INVALID = Symbol("invalid");
function parseId(raw: string | null): bigint | null | typeof INVALID {
if (raw === null) return null;
if (!NUMERIC_ID.test(raw)) return INVALID;
const parsed = BigInt(raw);
return parsed > 0n ? parsed : INVALID;
}
function parseBoolean(raw: string | null): boolean | typeof INVALID {
if (raw === null) return false;
if (raw === "true" || raw === "1") return true;
if (raw === "false" || raw === "0") return false;
return INVALID;
}
function parseLimit(raw: string | null): number | typeof INVALID {
if (raw === null) return DEFAULT_PAGE_SIZE;
if (!NUMERIC_ID.test(raw)) return INVALID;
const parsed = Number(raw);
if (parsed < 1 || parsed > MAX_PAGE_SIZE) return INVALID;
return parsed;
}

View file

@ -0,0 +1,216 @@
/**
* Query construction for the work-orders table (issue #419).
*
* The db module is stubbed *before* it is imported, so no `pg.Pool` is ever
* constructed and this suite cannot open a connection. What is under test is
* the SQL these builders emit, not its execution in particular that the
* derived filters cross into SQL as *data* (terminal stage ids, applicable
* requirement pairs) rather than as re-implemented rules.
*/
import { describe, expect, it, vi } from "vitest";
import { PgDialect } from "drizzle-orm/pg-core";
vi.mock("@/app/db/db", () => ({
db: {
execute: () => {
throw new Error("queries.test must not execute queries");
},
},
pool: {},
}));
import { buildWorkOrderCountQuery, buildWorkOrderPageQuery } from "./queries";
import { DEFAULT_QUERY, type WorkOrderQuery } from "./filters";
const dialect = new PgDialect();
const render = (query: ReturnType<typeof buildWorkOrderPageQuery>) =>
dialect.sqlToQuery(query);
const ASOF = new Date("2026-07-23T09:30:00Z");
const CONTRACTOR = "22222222-2222-2222-2222-222222222222";
const TERMINAL = [103n, 203n];
const query = (overrides: Partial<WorkOrderQuery> = {}): WorkOrderQuery => ({
...DEFAULT_QUERY,
...overrides,
});
const page = (q: Partial<WorkOrderQuery> = {}, terminal = TERMINAL) =>
render(buildWorkOrderPageQuery(7n, query(q), ASOF, [], terminal));
describe("buildWorkOrderPageQuery", () => {
it("scopes to the project and binds the id as a parameter", () => {
const { sql, params } = page();
expect(sql).toContain("pw.project_id =");
expect(params).toContain(7n);
});
it("asks for one row more than the page size, to detect a next page", () => {
const { params } = page({ limit: 50 });
expect(params).toContain(51);
});
it("orders by work_order id so a cursor survives any filter combination", () => {
expect(page().sql).toContain("ORDER BY id ASC");
});
it("applies the cursor as a keyset comparison, not an offset", () => {
const { sql, params } = page({ cursor: 812n });
expect(sql).toContain("id >");
expect(sql).not.toContain("OFFSET");
expect(params).toContain(812n);
});
it("emits no WHERE over the aggregate when nothing needs one", () => {
expect(page().sql).not.toContain("satisfied_count <");
});
describe("filters", () => {
it("narrows to a Project Workstream", () => {
const { sql, params } = page({ workstreamId: 10n });
expect(sql).toContain("pw.id =");
expect(params).toContain(10n);
});
it("narrows to a stage", () => {
const { sql, params } = page({ stageId: 102n });
expect(sql).toContain("wo.project_workstream_stage_id =");
expect(params).toContain(102n);
});
it("narrows to a contractor organisation, cast to uuid", () => {
const { sql, params } = page({ contractorOrganisationId: CONTRACTOR });
expect(sql).toContain("pwc.organisation_id =");
expect(sql).toContain("::uuid");
expect(params).toContain(CONTRACTOR);
});
it("composes several filters rather than replacing one with another", () => {
const { sql, params } = page({
workstreamId: 10n,
stageId: 102n,
contractorOrganisationId: CONTRACTOR,
overdueOnly: true,
missingEvidence: true,
});
expect(sql).toContain("pw.id =");
expect(sql).toContain("wo.project_workstream_stage_id =");
expect(sql).toContain("pwc.organisation_id =");
expect(sql).toContain("wo.forecast_end <");
expect(sql).toContain("satisfied_count < required_count");
expect(params).toEqual(
expect.arrayContaining([7n, 10n, 102n, CONTRACTOR, "2026-07-23"]),
);
});
});
describe("overdue", () => {
it("binds the caller's day rather than trusting the database clock", () => {
const { sql, params } = page({ overdueOnly: true });
expect(params).toContain("2026-07-23");
expect(sql).not.toContain("CURRENT_DATE");
});
it("excludes terminal stages using ids the ladders classified in JS", () => {
const { sql } = page({ overdueOnly: true });
expect(sql).toContain(
"wo.project_workstream_stage_id NOT IN (103, 203)",
);
// Terminal-ness is a ladder question; the query must not attempt it.
expect(sql).not.toMatch(/max\s*\(\s*"?order"?\s*\)/i);
});
it("omits the exclusion when no stage is terminal, rather than emitting NOT IN ()", () => {
const { sql } = page({ overdueOnly: true }, []);
expect(sql).toContain("wo.forecast_end <");
expect(sql).not.toContain("NOT IN ()");
});
it("does not touch forecast_end at all when the filter is off", () => {
expect(page().sql).not.toContain("wo.forecast_end <");
});
});
describe("evidence", () => {
it("filters missing evidence before LIMIT, so pagination stays stable", () => {
const { sql } = page({ missingEvidence: true });
const whereAt = sql.indexOf("satisfied_count < required_count");
const limitAt = sql.lastIndexOf("LIMIT");
expect(whereAt).toBeGreaterThan(-1);
expect(whereAt).toBeLessThan(limitAt);
});
it("counts against the applicable pairs the derivations module supplied", () => {
const { sql } = render(
buildWorkOrderPageQuery(
7n,
query(),
ASOF,
[
{ stageId: 102n, requirementId: 7n },
{ stageId: 103n, requirementId: 8n },
],
TERMINAL,
),
);
expect(sql).toContain("(102::bigint, 7::bigint)");
expect(sql).toContain("(103::bigint, 8::bigint)");
expect(sql).toContain("AS v(stage_id, requirement_id)");
});
it("emits a well-typed empty CTE when nothing is required, not empty VALUES", () => {
const { sql } = page();
expect(sql).toContain("WHERE false");
expect(sql).not.toMatch(/VALUES\s*\)/);
});
it("only counts Projects-sourced files as Evidence", () => {
expect(page().sql).toContain("uf.file_source = 'projects'");
});
});
it("left-joins the contractor assignment so an unassigned order still appears", () => {
expect(page().sql).toContain("LEFT JOIN project_workstream_contractor");
});
it("selects the priority flag and groups by it", () => {
const { sql } = page();
expect(sql).toContain("wo.priority");
// Selected alongside an aggregate, so it has to appear in the GROUP BY.
expect(sql).toMatch(/GROUP BY[^)]*wo\.priority/);
});
it("reads the stage and workstream names live, never from the work order", () => {
const { sql } = page();
expect(sql).toContain("pws.name");
expect(sql).toContain("w.name");
});
it("rejects an invalid asOf rather than issuing a query with a null date", () => {
expect(() =>
buildWorkOrderPageQuery(7n, query(), new Date("nope"), [], TERMINAL),
).toThrow();
});
});
describe("buildWorkOrderCountQuery", () => {
const count = (q: Partial<WorkOrderQuery> = {}) =>
render(buildWorkOrderCountQuery(7n, query(q), ASOF, [], TERMINAL));
it("counts the filtered set", () => {
const { sql } = count({ overdueOnly: true });
expect(sql).toContain("count(*)::int");
expect(sql).toContain("wo.forecast_end <");
});
it("ignores the cursor — the footer counts matches, not what is left", () => {
const { sql } = count({ cursor: 812n });
expect(sql).not.toContain("id >");
});
it("still applies the evidence filter", () => {
expect(count({ missingEvidence: true }).sql).toContain(
"satisfied_count < required_count",
);
});
});

View file

@ -0,0 +1,416 @@
/**
* Persistence for the admin work-orders table (issue #419).
*
* The rule for this file is the one `@/app/repositories/projects/dashboardRepository`
* set for the dashboard: **SQL counts facts, TypeScript makes judgements.**
* A programme runs to thousands of work orders, so filtering and pagination
* must happen in the database but no rule may be restated here in SQL, or the
* table's badges and the dashboard's KPIs will eventually disagree.
*
* So each derived filter is split at its `AND`, exactly as the dashboard splits
* it, and the TypeScript half crosses into SQL **as data**:
*
* - **overdue** `buildStageLadders` classifies the project's few dozen
* stages in JS; `stageIdsByProgress("complete")` hands the terminal ids to
* the query, which then counts `forecast_end < asOf AND stage NOT IN
* (terminal)`. SQL is never asked what "terminal" means.
* - **missing required evidence** `requiredEvidenceRequirementIds` decides
* which requirement applies at which stage and hands over an explicit
* `(stage_id, requirement_id)` list. The query counts against that list.
* `evidenceCompleteness` clamps `satisfied` into `[0, required]`, so
* `isMissingRequiredEvidence` is exactly `satisfied_count < required_count`
* the predicate the outer `WHERE` uses to filter *before* `LIMIT`, which
* is the one thing a JS-side filter could not do correctly.
*
* Every row the query returns still gets its badges from `toWorkOrderRow`, so
* what the user sees is always the pure module's answer.
*
* Read-only: this module issues `SELECT`s and nothing else.
*/
import { db } from "@/app/db/db";
import { and, asc, eq, sql, type SQL } from "drizzle-orm";
import {
projectWorkstream,
projectWorkstreamContractor,
projectWorkstreamStage,
workstream,
} from "@/app/db/schema/projects/projects";
import { organisation } from "@/app/db/schema/organisation";
import { buildStageLadders, toCalendarDay } from "@/lib/projects/derivations";
import {
buildApplicableEvidencePairs,
loadEvidenceRequirements,
loadStageLadderEntries,
type ApplicableEvidencePair,
} from "@/app/repositories/projects/dashboardRepository";
import type { WorkOrderFilterOptions, WorkOrderQuery } from "./filters";
import { toWorkOrderRow, type RawWorkOrderRow, type WorkOrderPage } from "./rows";
/**
* Load one page of the table.
*
* The stage ladders and evidence requirements are read first because the
* filters that depend on them (`overdue`, `missingEvidence`) can only be pushed
* into SQL once the pure module has been asked its questions. `asOf` is bound
* as a date parameter and reused for the row badges, never `CURRENT_DATE`.
*/
export async function loadWorkOrderPage(
projectId: bigint,
query: WorkOrderQuery,
asOf: Date,
): Promise<WorkOrderPage> {
const [stages, requirements] = await Promise.all([
loadStageLadderEntries(projectId),
loadEvidenceRequirements(projectId),
]);
const ladders = buildStageLadders(stages);
const applicableEvidence = buildApplicableEvidencePairs(
ladders,
stages,
requirements,
);
const terminalStageIds = ladders.stageIdsByProgress("complete");
const [pageRows, totalRows] = await Promise.all([
db.execute<WorkOrderQueryRow>(
buildWorkOrderPageQuery(
projectId,
query,
asOf,
applicableEvidence,
terminalStageIds,
),
),
db.execute<{ total: number }>(
buildWorkOrderCountQuery(
projectId,
query,
asOf,
applicableEvidence,
terminalStageIds,
),
),
]);
// One row over the page size was requested: its presence — not a count
// comparison — is what proves another page exists.
const rows = pageRows.rows.map(toRawWorkOrderRow);
const hasMore = rows.length > query.limit;
const page = hasMore ? rows.slice(0, query.limit) : rows;
return {
workOrders: page.map((raw) => toWorkOrderRow(ladders, raw, asOf)),
nextCursor: hasMore ? page[page.length - 1].id.toString() : null,
total: Number(totalRows.rows[0]?.total ?? 0),
};
}
/** The choices for the three dropdowns, in one round trip each. */
export async function loadFilterOptions(
projectId: bigint,
): Promise<WorkOrderFilterOptions> {
const [workstreams, contractors, stages] = await Promise.all([
db
.select({ id: projectWorkstream.id, name: workstream.name })
.from(projectWorkstream)
.innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id))
.where(eq(projectWorkstream.projectId, projectId))
.orderBy(workstream.name),
db
.selectDistinct({
organisationId: projectWorkstreamContractor.organisationId,
name: organisation.name,
})
.from(projectWorkstreamContractor)
.innerJoin(
projectWorkstream,
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
)
.innerJoin(
organisation,
eq(organisation.id, projectWorkstreamContractor.organisationId),
)
.where(eq(projectWorkstream.projectId, projectId))
.orderBy(organisation.name),
db
.select({
id: projectWorkstreamStage.id,
name: projectWorkstreamStage.name,
workstreamId: projectWorkstreamStage.projectWorkstreamId,
order: projectWorkstreamStage.order,
})
.from(projectWorkstreamStage)
.innerJoin(
projectWorkstream,
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id),
)
.where(eq(projectWorkstream.projectId, projectId))
.orderBy(
asc(projectWorkstreamStage.projectWorkstreamId),
asc(projectWorkstreamStage.order),
),
]);
return {
workstreams: workstreams.map((row) => ({
id: row.id.toString(),
name: row.name,
})),
// `organisation.name` is nullable; a contractor with no name still has to
// be selectable, so it is labelled by its id rather than dropped.
contractors: contractors.map((row) => ({
organisationId: row.organisationId,
name: row.name ?? `Organisation ${row.organisationId}`,
})),
stages: stages.map((row) => ({
id: row.id.toString(),
name: row.name,
workstreamId: row.workstreamId.toString(),
order: row.order,
})),
};
}
/** The raw column shape the page query returns. Postgres counts arrive as strings. */
interface WorkOrderQueryRow extends Record<string, unknown> {
id: string;
reference: string;
landlord_property_id: string | null;
address: string | null;
postcode: string | null;
workstream_id: string;
workstream_name: string;
contractor_organisation_id: string | null;
contractor_name: string | null;
stage_id: string;
stage_name: string;
forecast_end: string | null;
priority: boolean;
required_count: string | number;
satisfied_count: string | number;
}
function toRawWorkOrderRow(row: WorkOrderQueryRow): RawWorkOrderRow {
return {
id: BigInt(row.id),
reference: row.reference,
landlordPropertyId: row.landlord_property_id,
address: row.address,
postcode: row.postcode,
workstreamId: BigInt(row.workstream_id),
workstreamName: row.workstream_name,
contractorOrganisationId: row.contractor_organisation_id,
contractorName: row.contractor_name,
stageId: BigInt(row.stage_id),
stageName: row.stage_name,
// A `date` column arrives as `YYYY-MM-DD`; take the day part defensively so
// a driver that ever hands back a timestamp cannot leak a time into the UI.
forecastEnd: toCalendarDay(row.forecast_end),
// `work_order.priority` is a boolean column; `Boolean()` guards only against
// a driver handing back null for a row written before it was NOT NULL.
priority: Boolean(row.priority),
requiredEvidence: Number(row.required_count),
satisfiedEvidence: Number(row.satisfied_count),
};
}
/**
* Build the page query, separately from running it, so a unit test can render
* and assert on the SQL without opening a connection.
*
* `limit + 1` rows are requested: see `loadWorkOrderPage`.
*/
export function buildWorkOrderPageQuery(
projectId: bigint,
query: WorkOrderQuery,
asOf: Date,
applicableEvidence: readonly ApplicableEvidencePair[],
terminalStageIds: readonly bigint[],
) {
const scope = workOrderScope(
projectId,
query,
asOf,
applicableEvidence,
terminalStageIds,
);
return sql`${scope} SELECT * FROM per_order ${outerWhere(
query,
true,
)} ORDER BY id ASC LIMIT ${query.limit + 1}`;
}
/**
* The same filtered set, counted. The cursor is deliberately *not* applied
* the footer says how many work orders match the filters, not how many are left
* after the page the user is on.
*/
export function buildWorkOrderCountQuery(
projectId: bigint,
query: WorkOrderQuery,
asOf: Date,
applicableEvidence: readonly ApplicableEvidencePair[],
terminalStageIds: readonly bigint[],
) {
const scope = workOrderScope(
projectId,
query,
asOf,
applicableEvidence,
terminalStageIds,
);
return sql`${scope} SELECT count(*)::int AS total FROM per_order ${outerWhere(
query,
false,
)}`;
}
/**
* The `WITH applicable … per_order` prelude both queries share: the joins, the
* filters that can be pushed down, and the per-work-order evidence counts.
*
* The evidence counts are aggregated per work order here rather than in the
* outer query so the `missingEvidence` filter can be a plain `WHERE` over
* `per_order` and therefore run *before* `LIMIT`, which is what makes
* pagination stable under that filter.
*/
function workOrderScope(
projectId: bigint,
query: WorkOrderQuery,
asOf: Date,
applicableEvidence: readonly ApplicableEvidencePair[],
terminalStageIds: readonly bigint[],
) {
const asOfDay = toCalendarDay(asOf);
if (asOfDay === null) throw new Error("loadWorkOrderPage: invalid asOf date");
// Ids are interpolated as bigint literals, never as user text: they come from
// rows this process just read, and `asBigIntLiteral` re-checks each one.
const pairsSql = applicableEvidence.length
? sql.raw(
applicableEvidence
.map(
(pair) =>
`(${asBigIntLiteral(pair.stageId)}::bigint, ${asBigIntLiteral(
pair.requirementId,
)}::bigint)`,
)
.join(", "),
)
: null;
// `VALUES` with no rows is not legal SQL, so an empty applicable set still
// needs a well-typed, empty CTE.
const applicableCte = pairsSql
? sql`SELECT * FROM (VALUES ${pairsSql}) AS v(stage_id, requirement_id)`
: sql`SELECT NULL::bigint AS stage_id, NULL::bigint AS requirement_id WHERE false`;
const conditions = [sql`pw.project_id = ${projectId}`];
if (query.workstreamId !== null) {
conditions.push(sql`pw.id = ${query.workstreamId}`);
}
if (query.stageId !== null) {
conditions.push(sql`wo.project_workstream_stage_id = ${query.stageId}`);
}
if (query.contractorOrganisationId !== null) {
conditions.push(
sql`pwc.organisation_id = ${query.contractorOrganisationId}::uuid`,
);
}
if (query.overdueOnly) {
// The SQL half of `isOverdue`. A work order with no forecast end is never
// overdue, and `NULL < date` is NULL, so it drops out without a special case.
conditions.push(sql`wo.forecast_end < ${asOfDay}::date`);
// The TypeScript half, pushed down as data: complete work orders are never
// overdue however late they finished. An empty ladder set means no stage is
// terminal, so there is nothing to exclude.
if (terminalStageIds.length > 0) {
conditions.push(
sql`wo.project_workstream_stage_id NOT IN (${sql.raw(
terminalStageIds.map(asBigIntLiteral).join(", "),
)})`,
);
}
}
return sql`
WITH applicable AS (${applicableCte}),
per_order AS (
SELECT
wo.id,
wo.reference,
wo.forecast_end,
wo.priority,
wo.project_workstream_stage_id AS stage_id,
pws.name AS stage_name,
pw.id AS workstream_id,
w.name AS workstream_name,
p.landlord_property_id,
p.address,
p.postcode,
pwc.organisation_id AS contractor_organisation_id,
org.name AS contractor_name,
(
SELECT count(*) FROM applicable a
WHERE a.stage_id = wo.project_workstream_stage_id
) AS required_count,
count(DISTINCT uf.project_workstream_evidence_requirement_id)
AS satisfied_count
FROM work_order wo
JOIN project_workstream_stage pws
ON pws.id = wo.project_workstream_stage_id
JOIN project_workstream pw
ON pw.id = pws.project_workstream_id
JOIN workstream w
ON w.id = pw.workstream_id
JOIN property p
ON p.id = wo.property_id
LEFT JOIN project_workstream_contractor pwc
ON pwc.id = wo.project_workstream_contractor_id
LEFT JOIN organisation org
ON org.id = pwc.organisation_id
LEFT JOIN uploaded_files uf
ON uf.work_order_id = wo.id
AND uf.file_source = 'projects'
AND EXISTS (
SELECT 1 FROM applicable a
WHERE a.stage_id = wo.project_workstream_stage_id
AND a.requirement_id = uf.project_workstream_evidence_requirement_id
)
WHERE ${and(...conditions)}
GROUP BY wo.id, wo.reference, wo.forecast_end, wo.priority,
wo.project_workstream_stage_id, pws.name, pw.id, w.name,
p.landlord_property_id, p.address, p.postcode,
pwc.organisation_id, org.name
)
`;
}
/**
* The conditions that can only be applied once the per-work-order evidence
* counts exist, plus the keyset cursor.
*/
function outerWhere(query: WorkOrderQuery, withCursor: boolean) {
const conditions: SQL[] = [];
if (query.missingEvidence) {
// `isMissingRequiredEvidence(evidenceCompleteness(required, satisfied))`,
// expressed over the counted columns — see the module header.
conditions.push(sql`satisfied_count < required_count`);
}
if (withCursor && query.cursor !== null) {
conditions.push(sql`id > ${query.cursor}`);
}
return conditions.length ? sql` WHERE ${and(...conditions)}` : sql``;
}
/** Render a bigint as a SQL literal, rejecting anything that is not one. */
function asBigIntLiteral(value: bigint): string {
if (typeof value !== "bigint") {
throw new Error("asBigIntLiteral: expected a bigint id");
}
return value.toString();
}

View file

@ -0,0 +1,194 @@
/**
* GET `/api/projects/[projectId]/work-orders` (issue #419).
*
* 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,
mockLoadWorkOrderPage,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockLoadWorkOrderPage: 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", () => ({ loadWorkOrderPage: mockLoadWorkOrderPage }));
import { GET } from "./route";
import { DEFAULT_PAGE_SIZE } from "./filters";
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 EMPTY_PAGE = { workOrders: [], nextCursor: null, total: 0 };
function signedInAs(organisationIds: string[], email = "someone@landlord.example") {
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
mockLoadAuthzUser.mockResolvedValue({ id: 7n, email, organisationIds });
}
function projectExists() {
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: false,
contractorOrganisationIds: [CONTRACTOR_ORG],
});
}
const params = (projectId = "5") => ({ params: Promise.resolve({ projectId }) });
const request = (search = "", projectId = "5") =>
new NextRequest(
`http://localhost/api/projects/${projectId}/work-orders${search}`,
);
beforeEach(() => {
vi.clearAllMocks();
mockLoadWorkOrderPage.mockResolvedValue(EMPTY_PAGE);
});
describe("GET", () => {
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await GET(request(), params());
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorised" });
});
it("400s on a non-numeric project id", async () => {
signedInAs([CLIENT_ORG]);
const res = await GET(request("", "abc"), params("abc"));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid project" });
});
it("404s when the project does not exist", async () => {
signedInAs([CLIENT_ORG]);
mockLoadProjectAuthzFacts.mockResolvedValue(null);
const res = await GET(request(), params());
expect(res.status).toBe(404);
});
it("404s rather than 403s for a user outside the project", async () => {
signedInAs([OTHER_ORG]);
projectExists();
const res = await GET(request(), params());
expect(res.status).toBe(404);
expect(mockLoadWorkOrderPage).not.toHaveBeenCalled();
});
it("403s a contractor: this is the admin table, and row scoping is a later issue", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await GET(request(), params());
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: "Forbidden" });
expect(mockLoadWorkOrderPage).not.toHaveBeenCalled();
});
it("serves a client-org member the unfiltered first page", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(request(), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual(EMPTY_PAGE);
expect(mockLoadWorkOrderPage).toHaveBeenCalledWith(
5n,
expect.objectContaining({
workstreamId: null,
overdueOnly: false,
cursor: null,
limit: DEFAULT_PAGE_SIZE,
}),
expect.any(Date),
);
});
it("serves an internal user on an admin-access project", async () => {
signedInAs([], "someone@domna.homes");
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: true,
contractorOrganisationIds: [],
});
const res = await GET(request(), params());
expect(res.status).toBe(200);
});
it("passes the parsed filters and cursor through to the query", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(
request(
`?workstreamId=10&stageId=102&contractorOrganisationId=${CONTRACTOR_ORG}` +
"&overdue=true&missingEvidence=true&cursor=812&limit=25",
),
params(),
);
expect(res.status).toBe(200);
expect(mockLoadWorkOrderPage).toHaveBeenCalledWith(
5n,
{
workstreamId: 10n,
contractorOrganisationId: CONTRACTOR_ORG,
stageId: 102n,
overdueOnly: true,
missingEvidence: true,
cursor: 812n,
limit: 25,
},
expect.any(Date),
);
});
it("400s a malformed filter instead of quietly ignoring it", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(request("?overdue=yes"), params());
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid overdue" });
expect(mockLoadWorkOrderPage).not.toHaveBeenCalled();
});
it("returns the page's cursor and total untouched", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockLoadWorkOrderPage.mockResolvedValue({
workOrders: [{ id: "812", reference: "WO-23881" }],
nextCursor: "812",
total: 1231,
});
const res = await GET(request(), params());
expect(await res.json()).toMatchObject({ nextCursor: "812", total: 1231 });
});
it("500s without leaking the driver error", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mockLoadWorkOrderPage.mockRejectedValue(new Error("connection reset"));
const res = await GET(request(), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
error: "Couldn't load the work orders",
});
consoleError.mockRestore();
});
});

View file

@ -0,0 +1,44 @@
/**
* `GET /api/projects/[projectId]/work-orders` (issue #419).
*
* One page of the admin work-orders table, filtered and keyset-paginated in the
* database. The client calls this on every filter change and page step through
* TanStack Query; the page itself server-renders the first page from the same
* `loadWorkOrderPage`, so the first paint and every refetch afterwards are
* produced by identical code.
*
* Read-only this route issues `SELECT`s and nothing else.
*/
import { NextRequest, NextResponse } from "next/server";
import { authorizeWorkOrdersRequest } from "./authorize";
import { parseWorkOrderQuery } from "./filters";
import { loadWorkOrderPage } from "./queries";
type Params = { params: Promise<{ projectId: string }> };
export async function GET(req: NextRequest, props: Params) {
const { projectId } = await props.params;
const auth = await authorizeWorkOrdersRequest(projectId);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const parsed = parseWorkOrderQuery(req.nextUrl.searchParams);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
try {
// One `asOf` per request, passed to both the SQL filter and the row badges,
// so a request that straddles midnight cannot classify a work order as
// overdue in the WHERE clause and not-overdue in its badge.
const page = await loadWorkOrderPage(auth.projectId, parsed.query, new Date());
return NextResponse.json(page);
} catch (err) {
console.error("GET /api/projects/[projectId]/work-orders failed:", err);
return NextResponse.json(
{ error: "Couldn't load the work orders" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,176 @@
/**
* Row derivation for the work-orders table (issue #419).
*
* The point of these tests is not to re-test `@/lib/projects/derivations`
* #418 owns that but to prove this table *asks* it rather than deciding for
* itself: a complete work order past its forecast day is not overdue, the
* evidence counts are clamped, and an unknown stage degrades honestly.
*
* In-memory fixtures throughout; no database is imported.
*/
import { describe, expect, it } from "vitest";
import { buildStageLadders, type StageLadderEntry } from "@/lib/projects/derivations";
import { toWorkOrderRow, type RawWorkOrderRow } from "./rows";
const WINDOWS = 10n;
const STAGES: StageLadderEntry[] = [
{ id: 101n, projectWorkstreamId: WINDOWS, order: 1 }, // not started
{ id: 102n, projectWorkstreamId: WINDOWS, order: 2 }, // in progress
{ id: 103n, projectWorkstreamId: WINDOWS, order: 3 }, // terminal
];
const LADDERS = buildStageLadders(STAGES);
const ASOF = new Date("2026-07-23T09:30:00Z");
function raw(overrides: Partial<RawWorkOrderRow> = {}): RawWorkOrderRow {
return {
id: 812n,
reference: "WO-23881",
landlordPropertyId: "100245",
address: "12 High St",
postcode: "M1 2AB",
workstreamId: WINDOWS,
workstreamName: "Windows",
contractorOrganisationId: "22222222-2222-2222-2222-222222222222",
contractorName: "Contractor A",
stageId: 102n,
stageName: "In progress",
forecastEnd: "2026-07-30",
priority: false,
requiredEvidence: 4,
satisfiedEvidence: 2,
...overrides,
};
}
describe("toWorkOrderRow", () => {
it("serialises every bigint id, which would not survive JSON", () => {
const row = toWorkOrderRow(LADDERS, raw(), ASOF);
expect(row.id).toBe("812");
expect(row.workstreamId).toBe("10");
expect(row.stageId).toBe("102");
});
it("keeps the forecast end in YYYY-MM-DD — the table does the formatting", () => {
expect(toWorkOrderRow(LADDERS, raw(), ASOF).forecastEnd).toBe("2026-07-30");
});
it("derives progress from the ladder, not from the stage name", () => {
expect(toWorkOrderRow(LADDERS, raw({ stageId: 101n }), ASOF).progress).toBe(
"not_started",
);
expect(toWorkOrderRow(LADDERS, raw({ stageId: 102n }), ASOF).progress).toBe(
"in_progress",
);
expect(toWorkOrderRow(LADDERS, raw({ stageId: 103n }), ASOF).progress).toBe(
"complete",
);
});
it("marks a work order past its forecast day overdue", () => {
const row = toWorkOrderRow(LADDERS, raw({ forecastEnd: "2026-07-22" }), ASOF);
expect(row.overdue).toBe(true);
});
it("never marks a completed work order overdue, however late it finished", () => {
const row = toWorkOrderRow(
LADDERS,
raw({ stageId: 103n, forecastEnd: "2026-01-01" }),
ASOF,
);
expect(row.overdue).toBe(false);
});
it("does not treat due-today as overdue", () => {
expect(
toWorkOrderRow(LADDERS, raw({ forecastEnd: "2026-07-23" }), ASOF).overdue,
).toBe(false);
});
it("treats a missing forecast end as never overdue", () => {
expect(
toWorkOrderRow(LADDERS, raw({ forecastEnd: null }), ASOF).overdue,
).toBe(false);
});
it("reports advisory evidence counts, flagging a shortfall", () => {
const row = toWorkOrderRow(LADDERS, raw(), ASOF);
expect(row.evidence).toEqual({
required: 4,
satisfied: 2,
missing: 2,
ratio: 0.5,
});
expect(row.missingRequiredEvidence).toBe(true);
});
it("distinguishes 'nothing required' from '0% of requirements met'", () => {
const row = toWorkOrderRow(
LADDERS,
raw({ requiredEvidence: 0, satisfiedEvidence: 0 }),
ASOF,
);
expect(row.evidence.ratio).toBeNull();
expect(row.missingRequiredEvidence).toBe(false);
});
it("cannot report over 100% from a stray duplicate submission", () => {
const row = toWorkOrderRow(
LADDERS,
raw({ requiredEvidence: 2, satisfiedEvidence: 5 }),
ASOF,
);
expect(row.evidence).toEqual({
required: 2,
satisfied: 2,
missing: 0,
ratio: 1,
});
expect(row.missingRequiredEvidence).toBe(false);
});
it("degrades honestly on a stage outside the loaded ladders", () => {
// Not complete, so still eligible to be overdue — the derivations module's
// documented edge case, reached rather than re-decided here.
const row = toWorkOrderRow(
LADDERS,
raw({ stageId: 999n, forecastEnd: "2026-07-01" }),
ASOF,
);
expect(row.progress).toBeNull();
expect(row.overdue).toBe(true);
});
it("carries the priority flag through as a stored fact, not a derivation", () => {
expect(toWorkOrderRow(LADDERS, raw({ priority: true }), ASOF).priority).toBe(
true,
);
expect(toWorkOrderRow(LADDERS, raw(), ASOF).priority).toBe(false);
});
it("keeps priority independent of overdue — a flag is not a deadline", () => {
// A priority work order comfortably inside its forecast is not overdue,
// and an overdue one nobody flagged is not a priority.
const flaggedOnTime = toWorkOrderRow(
LADDERS,
raw({ priority: true, forecastEnd: "2026-08-30" }),
ASOF,
);
expect(flaggedOnTime).toMatchObject({ priority: true, overdue: false });
const lateUnflagged = toWorkOrderRow(
LADDERS,
raw({ priority: false, forecastEnd: "2026-07-01" }),
ASOF,
);
expect(lateUnflagged).toMatchObject({ priority: false, overdue: true });
});
it("carries the live stage name through, so a rename propagates", () => {
const row = toWorkOrderRow(
LADDERS,
raw({ stageName: "Awaiting authorisation" }),
ASOF,
);
expect(row.stageName).toBe("Awaiting authorisation");
});
});

View file

@ -0,0 +1,144 @@
/**
* Turning a `work_order` row into a table row (issue #419).
*
* This is where the shared derivations from `@/lib/projects/derivations` (#418)
* are applied to a single work order. Nothing here decides what *overdue*,
* *complete* or *evidence completeness* mean every one of those is a call
* into that module, so a badge in this table and a KPI on the programme
* dashboard cannot drift apart.
*
* Pure, like the module it wraps: no database client, no React. The query in
* `./queries` produces `RawWorkOrderRow` (facts, no judgement); this produces
* `WorkOrderRow` (facts plus the derived flags the table renders); the client
* component only ever sees the latter.
*
* Ids are serialised to strings on the way out because `bigint` does not
* survive `JSON.stringify` the same convention the workstreams endpoints use.
*/
import {
evidenceCompleteness,
isMissingRequiredEvidence,
isOverdue,
progressOfWorkOrder,
type EvidenceCompleteness,
type StageLadders,
type WorkOrderProgress,
} from "@/lib/projects/derivations";
/**
* One work order as the SQL returns it: joined-up facts, pre-judgement.
*
* `requiredEvidence` / `satisfiedEvidence` are counted against the applicable
* `(stage, requirement)` pairs that `requiredEvidenceRequirementIds` produced
* the query never decides applicability itself.
*/
export interface RawWorkOrderRow {
id: bigint;
reference: string;
/** `property.landlord_property_id` — the landlord's own asset reference. */
landlordPropertyId: string | null;
address: string | null;
postcode: string | null;
/** `project_workstream.id`. */
workstreamId: bigint;
/** `workstream.name` — reference data, so a rename propagates. */
workstreamName: string;
contractorOrganisationId: string | null;
contractorName: string | null;
stageId: bigint;
/**
* `project_workstream_stage.name`, read live through the work order's stage
* FK. The name is never denormalised onto the work order, so a stage rename
* shows up here on the next read an acceptance criterion of #419.
*/
stageName: string;
/** `work_order.forecast_end` as Drizzle hands back a `date`: `YYYY-MM-DD`. */
forecastEnd: string | null;
/**
* `work_order.priority` a flag, not a scale, and a stored fact rather than
* a derived one: someone marked this work order as needing attention first.
* Independent of `overdue`, which the ladder and the calendar decide.
*/
priority: boolean;
requiredEvidence: number;
satisfiedEvidence: number;
}
/** One rendered row of the work-orders table. */
export interface WorkOrderRow {
id: string;
reference: string;
landlordPropertyId: string | null;
address: string | null;
postcode: string | null;
workstreamId: string;
workstreamName: string;
contractorOrganisationId: string | null;
contractorName: string | null;
stageId: string;
stageName: string;
/** `YYYY-MM-DD`, the only date format that crosses this boundary. The table formats it. */
forecastEnd: string | null;
/** Flagged as priority. A stored fact, not a derivation — see `RawWorkOrderRow`. */
priority: boolean;
/** Null when the row's stage was not loaded into the ladders — see derivations. */
progress: WorkOrderProgress | null;
overdue: boolean;
/** Advisory only (CONTEXT.md): counts and badges, never a gate. */
evidence: EvidenceCompleteness;
missingRequiredEvidence: boolean;
}
/**
* Apply the shared rules to one row.
*
* `asOf` is the day the caller is measuring against the *same* day the SQL
* filter binds, so a request that straddles midnight cannot classify a row as
* overdue in the WHERE clause and not-overdue in the badge.
*/
export function toWorkOrderRow(
ladders: StageLadders,
raw: RawWorkOrderRow,
asOf: Date,
): WorkOrderRow {
const derivationInput = {
projectWorkstreamStageId: raw.stageId,
forecastEnd: raw.forecastEnd,
};
const evidence = evidenceCompleteness(
raw.requiredEvidence,
raw.satisfiedEvidence,
);
return {
id: raw.id.toString(),
reference: raw.reference,
landlordPropertyId: raw.landlordPropertyId,
address: raw.address,
postcode: raw.postcode,
workstreamId: raw.workstreamId.toString(),
workstreamName: raw.workstreamName,
contractorOrganisationId: raw.contractorOrganisationId,
contractorName: raw.contractorName,
stageId: raw.stageId.toString(),
stageName: raw.stageName,
forecastEnd: raw.forecastEnd,
priority: raw.priority,
progress: progressOfWorkOrder(ladders, derivationInput),
overdue: isOverdue(ladders, derivationInput, asOf),
evidence,
missingRequiredEvidence: isMissingRequiredEvidence(evidence),
};
}
/** One page of the table, as the route handler returns it. */
export interface WorkOrderPage {
workOrders: WorkOrderRow[];
/**
* The cursor that fetches the following page, or null on the last page.
* Its presence is how the client knows there is a next page at all.
*/
nextCursor: string | null;
/** Work orders matching the filters, ignoring pagination. */
total: number;
}

View file

@ -0,0 +1,265 @@
/**
* PATCH / DELETE `…/evidence-requirements/[requirementId]` (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,
mockResolveProjectWorkstream,
mockFindRequirement,
mockStageBelongsToWorkstream,
mockFindDuplicateRequirement,
mockUpdateRequirement,
mockCountSubmissionsFor,
mockDeleteRequirement,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockResolveProjectWorkstream: vi.fn(),
mockFindRequirement: vi.fn(),
mockStageBelongsToWorkstream: vi.fn(),
mockFindDuplicateRequirement: vi.fn(),
mockUpdateRequirement: vi.fn(),
mockCountSubmissionsFor: vi.fn(),
mockDeleteRequirement: 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", () => ({
resolveProjectWorkstream: mockResolveProjectWorkstream,
findRequirement: mockFindRequirement,
stageBelongsToWorkstream: mockStageBelongsToWorkstream,
findDuplicateRequirement: mockFindDuplicateRequirement,
updateRequirement: mockUpdateRequirement,
countSubmissionsFor: mockCountSubmissionsFor,
deleteRequirement: mockDeleteRequirement,
}));
import { DELETE, PATCH } from "./route";
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
const STORED = {
id: "77",
fileType: "handover_pack" as const,
projectWorkstreamStageId: "32",
required: true,
namingRule: "[WORef]_Handover",
};
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 = (requirementId = "77") => ({
params: Promise.resolve({
projectId: "5",
workstreamId: "1",
requirementId,
}),
});
const url = (requirementId = "77") =>
`http://localhost/api/projects/5/workstreams/1/evidence-requirements/${requirementId}`;
const patchRequest = (body: unknown, requirementId = "77") =>
new NextRequest(url(requirementId), {
method: "PATCH",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
const deleteRequest = (requirementId = "77") =>
new NextRequest(url(requirementId), { method: "DELETE" });
beforeEach(() => {
vi.clearAllMocks();
mockResolveProjectWorkstream.mockResolvedValue(9n);
mockFindRequirement.mockResolvedValue(STORED);
mockStageBelongsToWorkstream.mockResolvedValue(true);
mockFindDuplicateRequirement.mockResolvedValue(false);
mockUpdateRequirement.mockResolvedValue({ ...STORED, required: false });
mockCountSubmissionsFor.mockResolvedValue(0);
mockDeleteRequirement.mockResolvedValue(true);
});
describe("PATCH", () => {
it("403s for a contractor", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await PATCH(patchRequest({ required: false }), params());
expect(res.status).toBe(403);
expect(mockUpdateRequirement).not.toHaveBeenCalled();
});
it("404s for a requirement on another project's workstream", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockFindRequirement.mockResolvedValue(null);
const res = await PATCH(patchRequest({ required: false }, "999"), params("999"));
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: "Requirement not found" });
expect(mockUpdateRequirement).not.toHaveBeenCalled();
});
it("400s on an empty body", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await PATCH(patchRequest({}), params());
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Nothing to update" });
});
it("writes only the column the request named", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await PATCH(patchRequest({ required: false }), params());
expect(res.status).toBe(200);
expect(mockUpdateRequirement).toHaveBeenCalledWith(9n, 77n, {
required: false,
});
});
it("clears the naming rule when it is sent empty", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
await PATCH(patchRequest({ namingRule: " " }), params());
expect(mockUpdateRequirement).toHaveBeenCalledWith(9n, 77n, {
namingRule: null,
});
});
it("clears the stage tag when it is sent null", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
await PATCH(patchRequest({ projectWorkstreamStageId: null }), params());
expect(mockUpdateRequirement).toHaveBeenCalledWith(9n, 77n, {
projectWorkstreamStageId: null,
});
expect(mockStageBelongsToWorkstream).not.toHaveBeenCalled();
});
it("400s on a stage from another workstream's ladder", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockStageBelongsToWorkstream.mockResolvedValue(false);
const res = await PATCH(
patchRequest({ projectWorkstreamStageId: "999" }),
params(),
);
expect(res.status).toBe(400);
expect(mockUpdateRequirement).not.toHaveBeenCalled();
});
it("checks the re-tagged row against what it would become", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
await PATCH(patchRequest({ projectWorkstreamStageId: "31" }), params());
// File type comes from the stored row, the stage from the request, and the
// row being edited is excluded from the duplicate search.
expect(mockFindDuplicateRequirement).toHaveBeenCalledWith(
9n,
"handover_pack",
31n,
77n,
);
});
it("409s when the edit would duplicate another row", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockFindDuplicateRequirement.mockResolvedValue(true);
const res = await PATCH(
patchRequest({ projectWorkstreamStageId: "31" }),
params(),
);
expect(res.status).toBe(409);
expect(mockUpdateRequirement).not.toHaveBeenCalled();
});
it("does not run the duplicate check for an unrelated edit", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
await PATCH(patchRequest({ namingRule: "[WORef]_X" }), params());
expect(mockFindDuplicateRequirement).not.toHaveBeenCalled();
});
});
describe("DELETE", () => {
it("403s for a contractor", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await DELETE(deleteRequest(), params());
expect(res.status).toBe(403);
expect(mockDeleteRequirement).not.toHaveBeenCalled();
});
it("removes the requirement", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await DELETE(deleteRequest(), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ deleted: true });
expect(mockDeleteRequirement).toHaveBeenCalledWith(9n, 77n);
});
it("refuses once documents have been filed against it", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockCountSubmissionsFor.mockResolvedValue(2);
const res = await DELETE(deleteRequest(), params());
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ blocked: true, submissions: 2 });
expect(mockDeleteRequirement).not.toHaveBeenCalled();
});
it("404s for a requirement that is not on this workstream", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockFindRequirement.mockResolvedValue(null);
const res = await DELETE(deleteRequest("999"), params("999"));
expect(res.status).toBe(404);
expect(mockDeleteRequirement).not.toHaveBeenCalled();
});
it("500s without leaking the driver error", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mockDeleteRequirement.mockRejectedValue(new Error("connection reset"));
const res = await DELETE(deleteRequest(), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
error: "Couldn't remove the requirement",
});
consoleError.mockRestore();
});
});

View file

@ -0,0 +1,237 @@
/**
* `…/evidence-requirements/[requirementId]` (issue #412).
*
* PATCH edit one row of the checklist.
* DELETE remove it.
*
* Both are scoped through the workstream in the path: the requirement id is
* only ever looked up alongside its `project_workstream_id`, so a real id
* belonging to another project's workstream reads as "not found" rather than as
* something to edit. See `../route.ts` for why the segment above is
* `[workstreamId]` and carries the catalogue id.
*/
import { NextRequest, NextResponse } from "next/server";
import { authorizeWorkstreamRequest } from "../../../authorize";
import {
countSubmissionsFor,
deleteRequirement,
findDuplicateRequirement,
findRequirement,
resolveProjectWorkstream,
stageBelongsToWorkstream,
updateRequirement,
type EvidenceRequirement,
} from "../queries";
import { updateRequirementSchema } from "../schemas";
type Params = {
params: Promise<{
projectId: string;
workstreamId: string;
requirementId: string;
}>;
};
type Target =
| {
ok: true;
projectWorkstreamId: bigint;
requirementId: bigint;
/** The stored row, so an edit can be checked against what it would become. */
existing: EvidenceRequirement;
}
| { ok: false; error: string; status: number };
/** Resolve project → workstream → requirement, refusing at the first gap. */
async function resolveTarget(props: Params): Promise<Target> {
const { projectId, workstreamId, requirementId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) return { ok: false, error: auth.error, status: auth.status };
if (!/^\d+$/.test(workstreamId)) {
return { ok: false, error: "Invalid workstream", status: 400 };
}
if (!/^\d+$/.test(requirementId)) {
return { ok: false, error: "Invalid requirement", status: 400 };
}
const projectWorkstreamId = await resolveProjectWorkstream(
auth.projectId,
BigInt(workstreamId),
);
if (projectWorkstreamId === null) {
return {
ok: false,
error: "Workstream is not selected on this project",
status: 404,
};
}
const existing = await findRequirement(
projectWorkstreamId,
BigInt(requirementId),
);
if (!existing) {
return { ok: false, error: "Requirement not found", status: 404 };
}
return {
ok: true,
projectWorkstreamId,
requirementId: BigInt(requirementId),
existing,
};
}
export async function PATCH(req: NextRequest, props: Params) {
const target = await resolveTarget(props);
if (!target.ok) {
return NextResponse.json(
{ error: target.error },
{ status: target.status },
);
}
const parsed = updateRequirementSchema.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;
// `undefined` means "leave this column alone"; `null` means "clear it". Only
// the keys the request actually sent are turned into column writes.
const stageId =
draft.projectWorkstreamStageId === undefined
? undefined
: draft.projectWorkstreamStageId === null
? null
: BigInt(draft.projectWorkstreamStageId);
if (
stageId !== undefined &&
stageId !== null &&
!(await stageBelongsToWorkstream(target.projectWorkstreamId, stageId))
) {
return NextResponse.json(
{ error: "That stage is not on this workstream" },
{ status: 400 },
);
}
// A re-tag can collide with an existing row, so the duplicate check runs
// against what the row would look like afterwards — reading the untouched
// fields back off the stored row.
if (draft.fileType !== undefined || stageId !== undefined) {
const current = target.existing;
const nextFileType = draft.fileType ?? current.fileType;
const nextStageId =
stageId === undefined
? current.projectWorkstreamStageId === null
? null
: BigInt(current.projectWorkstreamStageId)
: stageId;
if (
await findDuplicateRequirement(
target.projectWorkstreamId,
nextFileType,
nextStageId,
target.requirementId,
)
) {
return NextResponse.json(
{ error: "That document is already on this workstream's checklist" },
{ status: 409 },
);
}
}
try {
const requirement = await updateRequirement(
target.projectWorkstreamId,
target.requirementId,
{
...(draft.fileType !== undefined ? { fileType: draft.fileType } : {}),
...(stageId !== undefined
? { projectWorkstreamStageId: stageId }
: {}),
...(draft.required !== undefined ? { required: draft.required } : {}),
...(draft.namingRule !== undefined
? { namingRule: draft.namingRule }
: {}),
},
);
if (!requirement) {
return NextResponse.json(
{ error: "Requirement not found" },
{ status: 404 },
);
}
return NextResponse.json({ requirement });
} catch (err) {
console.error(
"PATCH …/evidence-requirements/[requirementId] failed:",
err,
);
return NextResponse.json(
{ error: "Couldn't save the requirement" },
{ status: 500 },
);
}
}
export async function DELETE(_req: NextRequest, props: Params) {
const target = await resolveTarget(props);
if (!target.ok) {
return NextResponse.json(
{ error: target.error },
{ status: target.status },
);
}
// Configuration may be withdrawn; documents already filed against it may not
// be orphaned. The `uploaded_files` FK would refuse the delete anyway — this
// turns that into an explanation rather than a 500.
const submissions = await countSubmissionsFor(target.requirementId);
if (submissions > 0) {
return NextResponse.json(
{
error: `${submissions} uploaded ${
submissions === 1 ? "document has" : "documents have"
} been filed against this requirement, so it can't be removed.`,
blocked: true,
submissions,
},
{ status: 409 },
);
}
try {
const removed = await deleteRequirement(
target.projectWorkstreamId,
target.requirementId,
);
if (!removed) {
return NextResponse.json(
{ error: "Requirement not found" },
{ status: 404 },
);
}
return NextResponse.json({ deleted: true });
} catch (err) {
console.error(
"DELETE …/evidence-requirements/[requirementId] failed:",
err,
);
return NextResponse.json(
{ error: "Couldn't remove the requirement" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,415 @@
/**
* Persistence for the evidence-checklist setup screen (issue #412).
*
* The route handlers and the server-rendered page both read through here, so
* the first paint and every later refetch agree on shape the same
* arrangement `../../queries.ts` uses for the workstream grid.
*
* Nothing in this file decides anything: the handlers have already asked
* `@/lib/projects/authz` who the caller is by the time they call in, and
* evidence requirements are advisory in v1, so there is no gate to enforce
* (CONTEXT.md, "Evidence requirement"; ADR-0019).
*/
import { db } from "@/app/db/db";
import { and, asc, eq, inArray, isNull, ne, sql, type SQL } from "drizzle-orm";
import {
projectWorkstream,
projectWorkstreamEvidenceRequirement,
projectWorkstreamStage,
workstream,
} from "@/app/db/schema/projects/projects";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import type { FileTypeValue } from "./schemas";
/** One rung of a Project Workstream's ladder, as the stage-tag select shows it. */
export interface StageOption {
/** `project_workstream_stage.id`, serialised — bigints do not survive JSON. */
id: string;
name: string;
order: number;
}
/** One configured requirement — a row of the checklist table. */
export interface EvidenceRequirement {
/** `project_workstream_evidence_requirement.id`, serialised. */
id: string;
fileType: FileTypeValue;
/** Null when the document is expected of the workstream as a whole. */
projectWorkstreamStageId: string | null;
required: boolean;
namingRule: string | null;
}
/** One selected workstream and everything the checklist screen needs about it. */
export interface WorkstreamChecklist {
/**
* `workstream.id` the catalogue id, which is what the endpoints under
* `/workstreams/[workstreamId]/…` take (see `./route.ts`).
*/
workstreamId: string;
/** `project_workstream.id`, for callers that need the instance itself. */
projectWorkstreamId: string;
name: string;
/** This workstream's own ladder (#411); empty until it has one. */
stages: StageOption[];
requirements: EvidenceRequirement[];
}
function toStage(row: {
id: bigint;
name: string;
order: number;
}): StageOption {
return { id: row.id.toString(), name: row.name, order: row.order };
}
function toRequirement(row: {
id: bigint;
fileType: FileTypeValue;
projectWorkstreamStageId: bigint | null;
required: boolean;
namingRule: string | null;
}): EvidenceRequirement {
return {
id: row.id.toString(),
fileType: row.fileType,
projectWorkstreamStageId: row.projectWorkstreamStageId?.toString() ?? null,
required: row.required,
namingRule: row.namingRule,
};
}
/**
* Every workstream this project has selected, with its ladder and its
* checklist. Three round trips, assembled in JS rather than one join, so the
* stage/requirement fan-out cannot multiply rows.
*
* A project with no workstreams selected yet returns `[]` a legitimate state
* this screen renders as "choose workstreams first", not an error.
*/
export async function loadProjectChecklists(
projectId: bigint,
): Promise<WorkstreamChecklist[]> {
const selections = await db
.select({
projectWorkstreamId: projectWorkstream.id,
workstreamId: workstream.id,
name: workstream.name,
})
.from(projectWorkstream)
.innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id))
.where(eq(projectWorkstream.projectId, projectId))
.orderBy(asc(workstream.id));
if (selections.length === 0) return [];
const ids = selections.map((s) => s.projectWorkstreamId);
const [stages, requirements] = await Promise.all([
db
.select({
projectWorkstreamId: projectWorkstreamStage.projectWorkstreamId,
id: projectWorkstreamStage.id,
name: projectWorkstreamStage.name,
order: projectWorkstreamStage.order,
})
.from(projectWorkstreamStage)
.where(inArray(projectWorkstreamStage.projectWorkstreamId, ids))
.orderBy(asc(projectWorkstreamStage.order), asc(projectWorkstreamStage.id)),
db
.select({
projectWorkstreamId:
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
id: projectWorkstreamEvidenceRequirement.id,
fileType: projectWorkstreamEvidenceRequirement.fileType,
projectWorkstreamStageId:
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
required: projectWorkstreamEvidenceRequirement.required,
namingRule: projectWorkstreamEvidenceRequirement.namingRule,
})
.from(projectWorkstreamEvidenceRequirement)
.where(inArray(projectWorkstreamEvidenceRequirement.projectWorkstreamId, ids))
.orderBy(asc(projectWorkstreamEvidenceRequirement.id)),
]);
return selections.map((selection) => {
const key = selection.projectWorkstreamId;
return {
workstreamId: selection.workstreamId.toString(),
projectWorkstreamId: key.toString(),
name: selection.name,
stages: stages.filter((s) => s.projectWorkstreamId === key).map(toStage),
requirements: requirements
.filter((r) => r.projectWorkstreamId === key)
.map(toRequirement),
};
});
}
/**
* The `project_workstream.id` for one (project, catalogue workstream) pair, or
* null when this project has not selected that workstream.
*
* This is the guard that keeps the endpoints project-scoped: a requirement is
* only ever reached through a workstream the caller's project has selected, so
* a valid id from another project reads as "not selected here" and 404s.
*/
export async function resolveProjectWorkstream(
projectId: bigint,
workstreamId: bigint,
): Promise<bigint | null> {
const [row] = await db
.select({ id: projectWorkstream.id })
.from(projectWorkstream)
.where(
and(
eq(projectWorkstream.projectId, projectId),
eq(projectWorkstream.workstreamId, workstreamId),
),
)
.limit(1);
return row?.id ?? null;
}
/** One workstream's ladder and checklist — what `GET` serves. */
export async function loadChecklist(projectWorkstreamId: bigint): Promise<{
stages: StageOption[];
requirements: EvidenceRequirement[];
}> {
const [stages, requirements] = await Promise.all([
db
.select({
id: projectWorkstreamStage.id,
name: projectWorkstreamStage.name,
order: projectWorkstreamStage.order,
})
.from(projectWorkstreamStage)
.where(eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId))
.orderBy(asc(projectWorkstreamStage.order), asc(projectWorkstreamStage.id)),
db
.select({
id: projectWorkstreamEvidenceRequirement.id,
fileType: projectWorkstreamEvidenceRequirement.fileType,
projectWorkstreamStageId:
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
required: projectWorkstreamEvidenceRequirement.required,
namingRule: projectWorkstreamEvidenceRequirement.namingRule,
})
.from(projectWorkstreamEvidenceRequirement)
.where(
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
projectWorkstreamId,
),
)
.orderBy(asc(projectWorkstreamEvidenceRequirement.id)),
]);
return { stages: stages.map(toStage), requirements: requirements.map(toRequirement) };
}
/**
* True when `stageId` is a rung of *this* workstream's ladder.
*
* Each Project Workstream owns its own ladder (CONTEXT.md, "Stage"), so a stage
* id from a sibling workstream is a bad request, not a tag we may store.
*/
export async function stageBelongsToWorkstream(
projectWorkstreamId: bigint,
stageId: bigint,
): Promise<boolean> {
const [row] = await db
.select({ id: projectWorkstreamStage.id })
.from(projectWorkstreamStage)
.where(
and(
eq(projectWorkstreamStage.id, stageId),
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId),
),
)
.limit(1);
return Boolean(row);
}
/** `null` stage tags have to be matched with IS NULL, not `= null`. */
function stageMatches(stageId: bigint | null): SQL | undefined {
return stageId === null
? isNull(projectWorkstreamEvidenceRequirement.projectWorkstreamStageId)
: eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
stageId,
);
}
/**
* Whether this workstream already asks for that document at that stage.
*
* The table carries no unique index, so the duplicate check is a read the
* handlers make before writing. `excludeId` lets an edit ignore the row being
* edited. The same file type at two *different* stages is legitimate a photo
* pack before and after the works is one document type, twice.
*/
export async function findDuplicateRequirement(
projectWorkstreamId: bigint,
fileType: FileTypeValue,
stageId: bigint | null,
excludeId?: bigint,
): Promise<boolean> {
const [row] = await db
.select({ id: projectWorkstreamEvidenceRequirement.id })
.from(projectWorkstreamEvidenceRequirement)
.where(
and(
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
projectWorkstreamId,
),
eq(projectWorkstreamEvidenceRequirement.fileType, fileType),
stageMatches(stageId),
excludeId === undefined
? undefined
: ne(projectWorkstreamEvidenceRequirement.id, excludeId),
),
)
.limit(1);
return Boolean(row);
}
/** The requirement row, scoped to the workstream it must hang off. */
export async function findRequirement(
projectWorkstreamId: bigint,
requirementId: bigint,
): Promise<EvidenceRequirement | null> {
const [row] = await db
.select({
id: projectWorkstreamEvidenceRequirement.id,
fileType: projectWorkstreamEvidenceRequirement.fileType,
projectWorkstreamStageId:
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
required: projectWorkstreamEvidenceRequirement.required,
namingRule: projectWorkstreamEvidenceRequirement.namingRule,
})
.from(projectWorkstreamEvidenceRequirement)
.where(
and(
eq(projectWorkstreamEvidenceRequirement.id, requirementId),
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
projectWorkstreamId,
),
),
)
.limit(1);
return row ? toRequirement(row) : null;
}
/** Add a requirement to a workstream's checklist. */
export async function insertRequirement(
projectWorkstreamId: bigint,
values: {
fileType: FileTypeValue;
projectWorkstreamStageId: bigint | null;
required: boolean;
namingRule: string | null;
},
): Promise<EvidenceRequirement> {
const [row] = await db
.insert(projectWorkstreamEvidenceRequirement)
.values({ projectWorkstreamId, ...values })
.returning({
id: projectWorkstreamEvidenceRequirement.id,
fileType: projectWorkstreamEvidenceRequirement.fileType,
projectWorkstreamStageId:
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
required: projectWorkstreamEvidenceRequirement.required,
namingRule: projectWorkstreamEvidenceRequirement.namingRule,
});
return toRequirement(row);
}
/**
* Apply an edit, writing only the columns the request named.
*
* An absent key leaves its column alone; a present key with `null` clears it.
* That distinction is what lets the stage select, the required toggle and the
* naming-rule field each save independently.
*/
export async function updateRequirement(
projectWorkstreamId: bigint,
requirementId: bigint,
values: {
fileType?: FileTypeValue;
projectWorkstreamStageId?: bigint | null;
required?: boolean;
namingRule?: string | null;
},
): Promise<EvidenceRequirement | null> {
const [row] = await db
.update(projectWorkstreamEvidenceRequirement)
.set(values)
.where(
and(
eq(projectWorkstreamEvidenceRequirement.id, requirementId),
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
projectWorkstreamId,
),
),
)
.returning({
id: projectWorkstreamEvidenceRequirement.id,
fileType: projectWorkstreamEvidenceRequirement.fileType,
projectWorkstreamStageId:
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
required: projectWorkstreamEvidenceRequirement.required,
namingRule: projectWorkstreamEvidenceRequirement.namingRule,
});
return row ? toRequirement(row) : null;
}
/**
* How many submitted files were filed against this requirement.
*
* Evidence is an `uploaded_files` row that points back at the requirement it
* satisfies (CONTEXT.md, "Evidence"), so a requirement with submissions cannot
* simply be deleted the FK would refuse, and silently detaching real
* documents to make the delete succeed would lose why they were uploaded. The
* handler turns a non-zero count into a 409.
*/
export async function countSubmissionsFor(
requirementId: bigint,
): Promise<number> {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(uploadedFiles)
.where(
eq(uploadedFiles.projectWorkstreamEvidenceRequirementId, requirementId),
);
return row?.count ?? 0;
}
/**
* Remove a requirement. Returns false when the id names no row *on this
* workstream*, which the handler reports as a 404.
*
* Callers must have checked `countSubmissionsFor` first: this issues the plain
* DELETE, which the `uploaded_files` FK would reject if evidence had been filed
* against the row.
*/
export async function deleteRequirement(
projectWorkstreamId: bigint,
requirementId: bigint,
): Promise<boolean> {
const deleted = await db
.delete(projectWorkstreamEvidenceRequirement)
.where(
and(
eq(projectWorkstreamEvidenceRequirement.id, requirementId),
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
projectWorkstreamId,
),
),
)
.returning({ id: projectWorkstreamEvidenceRequirement.id });
return deleted.length > 0;
}

View file

@ -0,0 +1,276 @@
/**
* GET / POST `…/workstreams/[workstreamId]/evidence-requirements` (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,
mockResolveProjectWorkstream,
mockLoadChecklist,
mockStageBelongsToWorkstream,
mockFindDuplicateRequirement,
mockInsertRequirement,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockResolveProjectWorkstream: vi.fn(),
mockLoadChecklist: vi.fn(),
mockStageBelongsToWorkstream: vi.fn(),
mockFindDuplicateRequirement: vi.fn(),
mockInsertRequirement: 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", () => ({
resolveProjectWorkstream: mockResolveProjectWorkstream,
loadChecklist: mockLoadChecklist,
stageBelongsToWorkstream: mockStageBelongsToWorkstream,
findDuplicateRequirement: mockFindDuplicateRequirement,
insertRequirement: mockInsertRequirement,
}));
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 CHECKLIST = {
stages: [
{ id: "31", name: "Ordered", order: 1 },
{ id: "32", name: "Completed", order: 2 },
],
requirements: [
{
id: "77",
fileType: "handover_pack",
projectWorkstreamStageId: "32",
required: true,
namingRule: "[AssetRef]_[WORef]_Handover",
},
],
};
function signedInAs(organisationIds: string[]) {
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
mockLoadAuthzUser.mockResolvedValue({
id: 7n,
email: "someone@landlord.example",
organisationIds,
});
}
function projectExists() {
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: false,
contractorOrganisationIds: [CONTRACTOR_ORG],
});
}
const params = (projectId = "5", workstreamId = "1") => ({
params: Promise.resolve({ projectId, workstreamId }),
});
const url = (projectId = "5", workstreamId = "1") =>
`http://localhost/api/projects/${projectId}/workstreams/${workstreamId}/evidence-requirements`;
const getRequest = (projectId = "5", workstreamId = "1") =>
new NextRequest(url(projectId, workstreamId));
const postRequest = (body: unknown) =>
new NextRequest(url(), {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
beforeEach(() => {
vi.clearAllMocks();
mockResolveProjectWorkstream.mockResolvedValue(9n);
mockLoadChecklist.mockResolvedValue(CHECKLIST);
mockStageBelongsToWorkstream.mockResolvedValue(true);
mockFindDuplicateRequirement.mockResolvedValue(false);
mockInsertRequirement.mockResolvedValue({
id: "78",
fileType: "photo_pack",
projectWorkstreamStageId: null,
required: true,
namingRule: null,
});
});
describe("GET", () => {
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await GET(getRequest(), params());
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorised" });
});
it("404s rather than 403s for a user outside the project", async () => {
signedInAs([OTHER_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(404);
expect(mockLoadChecklist).not.toHaveBeenCalled();
});
it("400s on a non-numeric workstream id", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(getRequest("5", "windows"), params("5", "windows"));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid workstream" });
});
it("404s when the project has not selected that workstream", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockResolveProjectWorkstream.mockResolvedValue(null);
const res = await GET(getRequest(), params());
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
error: "Workstream is not selected on this project",
});
expect(mockLoadChecklist).not.toHaveBeenCalled();
});
it("returns the checklist and the workstream's own stage ladder", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ...CHECKLIST, canManage: true });
expect(mockResolveProjectWorkstream).toHaveBeenCalledWith(5n, 1n);
expect(mockLoadChecklist).toHaveBeenCalledWith(9n);
});
it("lets a contractor read but marks the table unmanageable", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ canManage: false });
});
});
describe("POST", () => {
it("403s for a contractor, who may see setup but not edit it", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await POST(postRequest({ fileType: "photo_pack" }), params());
expect(res.status).toBe(403);
expect(mockInsertRequirement).not.toHaveBeenCalled();
});
it("400s on a file type outside the enum", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await POST(
postRequest({ fileType: "window_certificate" }),
params(),
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Unknown document type" });
expect(mockInsertRequirement).not.toHaveBeenCalled();
});
it("defaults to required, untagged and without a naming rule", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await POST(postRequest({ fileType: "photo_pack" }), params());
expect(res.status).toBe(201);
expect(mockInsertRequirement).toHaveBeenCalledWith(9n, {
fileType: "photo_pack",
projectWorkstreamStageId: null,
required: true,
namingRule: null,
});
});
it("stores the optional stage tag, and treats an empty one as untagged", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
await POST(
postRequest({ fileType: "photo_pack", projectWorkstreamStageId: "31" }),
params(),
);
expect(mockInsertRequirement).toHaveBeenLastCalledWith(
9n,
expect.objectContaining({ projectWorkstreamStageId: 31n }),
);
await POST(
postRequest({ fileType: "site_note", projectWorkstreamStageId: "" }),
params(),
);
expect(mockInsertRequirement).toHaveBeenLastCalledWith(
9n,
expect.objectContaining({ projectWorkstreamStageId: null }),
);
});
it("400s on a stage belonging to another workstream's ladder", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockStageBelongsToWorkstream.mockResolvedValue(false);
const res = await POST(
postRequest({ fileType: "photo_pack", projectWorkstreamStageId: "999" }),
params(),
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
error: "That stage is not on this workstream",
});
expect(mockInsertRequirement).not.toHaveBeenCalled();
});
it("409s rather than listing the same document at the same stage twice", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockFindDuplicateRequirement.mockResolvedValue(true);
const res = await POST(postRequest({ fileType: "photo_pack" }), params());
expect(res.status).toBe(409);
expect(mockInsertRequirement).not.toHaveBeenCalled();
});
it("keeps a naming rule, trimmed", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
await POST(
postRequest({ fileType: "photo_pack", namingRule: " [WORef]_Pack " }),
params(),
);
expect(mockInsertRequirement).toHaveBeenCalledWith(
9n,
expect.objectContaining({ namingRule: "[WORef]_Pack" }),
);
});
it("500s without leaking the driver error", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mockInsertRequirement.mockRejectedValue(new Error("connection reset"));
const res = await POST(postRequest({ fileType: "photo_pack" }), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "Couldn't add the requirement" });
consoleError.mockRestore();
});
});

View file

@ -0,0 +1,165 @@
/**
* `/api/projects/[projectId]/workstreams/[workstreamId]/evidence-requirements`
* (issue #412).
*
* GET one workstream's checklist, plus the stage ladder its optional stage
* tag is chosen from.
* POST add a requirement.
*
* ## The `[workstreamId]` segment
*
* #412 specifies this collection under `[projectWorkstreamId]`, but Next.js
* allows only **one** slug name per dynamic segment across a route tree, and
* `/workstreams/[workstreamId]` is already taken by the deselect endpoint
* (#410). Rather than rename someone else's live route, this collection hangs
* off the existing segment and keeps its existing meaning: the id in the URL is
* the **catalogue `workstream.id`**, exactly as the sibling DELETE reads it, so
* `/workstreams/9` names the same workstream everywhere. It is resolved to the
* `project_workstream.id` here, by way of a lookup that doubles as the
* project-scoping guard a workstream this project has not selected 404s.
*
* Evidence requirements are **advisory** (CONTEXT.md; ADR-0019): nothing here
* feeds the import readiness gate, and a workstream with an empty checklist is
* a valid, fully importable workstream.
*/
import { NextRequest, NextResponse } from "next/server";
import { authorizeWorkstreamRequest } from "../../authorize";
import {
findDuplicateRequirement,
insertRequirement,
loadChecklist,
resolveProjectWorkstream,
stageBelongsToWorkstream,
} from "./queries";
import { createRequirementSchema } from "./schemas";
type Params = { params: Promise<{ projectId: string; workstreamId: string }> };
/**
* Resolve the request's project and workstream together.
*
* `authorizeWorkstreamRequest` (#410) is imported as-is: it is the project-level
* rule for every endpoint under `/projects/[projectId]/workstreams`, and this
* collection has no permission rule of its own to add.
*/
async function resolveTarget(
props: Params,
capability: "view" | "manage",
): Promise<
| { ok: true; projectWorkstreamId: bigint; canManage: boolean }
| { ok: false; error: string; status: number }
> {
const { projectId, workstreamId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, capability);
if (!auth.ok) return { ok: false, error: auth.error, status: auth.status };
if (!/^\d+$/.test(workstreamId)) {
return { ok: false, error: "Invalid workstream", status: 400 };
}
const projectWorkstreamId = await resolveProjectWorkstream(
auth.projectId,
BigInt(workstreamId),
);
if (projectWorkstreamId === null) {
return {
ok: false,
error: "Workstream is not selected on this project",
status: 404,
};
}
return { ok: true, projectWorkstreamId, canManage: auth.canManage };
}
/** GET — the checklist table's data, re-read from the DB on every request. */
export async function GET(_req: NextRequest, props: Params) {
const target = await resolveTarget(props, "view");
if (!target.ok) {
return NextResponse.json(
{ error: target.error },
{ status: target.status },
);
}
const { stages, requirements } = await loadChecklist(
target.projectWorkstreamId,
);
return NextResponse.json({
stages,
requirements,
canManage: target.canManage,
});
}
/** POST — add a requirement to this workstream's checklist. */
export async function POST(req: NextRequest, props: Params) {
const target = await resolveTarget(props, "manage");
if (!target.ok) {
return NextResponse.json(
{ error: target.error },
{ status: target.status },
);
}
const parsed = createRequirementSchema.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;
const stageId =
draft.projectWorkstreamStageId === null
? null
: BigInt(draft.projectWorkstreamStageId);
// The stage tag is optional, but when given it must be a rung of *this*
// workstream's own ladder — every Project Workstream has its own.
if (
stageId !== null &&
!(await stageBelongsToWorkstream(target.projectWorkstreamId, stageId))
) {
return NextResponse.json(
{ error: "That stage is not on this workstream" },
{ status: 400 },
);
}
if (
await findDuplicateRequirement(
target.projectWorkstreamId,
draft.fileType,
stageId,
)
) {
return NextResponse.json(
{ error: "That document is already on this workstream's checklist" },
{ status: 409 },
);
}
try {
const requirement = await insertRequirement(target.projectWorkstreamId, {
fileType: draft.fileType,
projectWorkstreamStageId: stageId,
required: draft.required,
namingRule: draft.namingRule,
});
return NextResponse.json({ requirement }, { status: 201 });
} catch (err) {
console.error(
"POST /api/projects/[projectId]/workstreams/[workstreamId]/evidence-requirements failed:",
err,
);
return NextResponse.json(
{ error: "Couldn't add the requirement" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,78 @@
/**
* Request-body schemas for the evidence-requirement endpoints (issue #412).
*
* Db-free on purpose it imports the `file_type` pgEnum for its *values* only
* (`fileType.enumValues` is a plain string tuple), never a connection so the
* POST and the PATCH handlers can share one definition of what a requirement
* row may say.
*
* The document taxonomy is the existing `file_type` enum, not a new
* document-type table: a submitted file already carries a `file_type`, so a
* requirement expressed in the same vocabulary can be matched against it
* directly (CONTEXT.md, "Evidence requirement").
*/
import { z } from "zod";
import { fileType } from "@/app/db/schema/uploaded_files_enums";
/** Every `file_type` the picker may offer. */
export const FILE_TYPE_VALUES = fileType.enumValues;
export type FileTypeValue = (typeof FILE_TYPE_VALUES)[number];
/** Ids are bigints in the database and travel as numeric strings over JSON. */
const idString = z.string().regex(/^\d+$/, "Invalid id");
/**
* The optional stage tag.
*
* `null` is a real value here, not an omission: it means "this document is
* expected of the workstream as a whole", which is the column's own meaning of
* NULL. An empty string is accepted as the same thing because that is what an
* unselected `<select>` sends.
*/
const stageId = z.union([idString, z.literal(""), z.null()]).transform((v) => (v ? v : null));
/**
* The naming rule is a display-only hint a convention shown next to the
* requirement, never enforced against an uploaded file's name (#412 tasks).
*/
const namingRule = z
.union([z.string(), z.null()])
.transform((v) => (v === null ? null : v.trim()))
.refine((v) => v === null || v.length <= 200, {
message: "Naming rule must be 200 characters or fewer",
})
.transform((v) => (v ? v : null));
export const createRequirementSchema = z.object({
fileType: z.enum(FILE_TYPE_VALUES, {
errorMap: () => ({ message: "Unknown document type" }),
}),
projectWorkstreamStageId: stageId.optional().default(null),
required: z.boolean().optional().default(true),
namingRule: namingRule.optional().default(null),
});
export type CreateRequirementBody = z.output<typeof createRequirementSchema>;
/**
* An edit to one row. Every field is optional and only the keys actually
* present are written, so the table's three independent controls (stage tag,
* required toggle, naming rule) can each save on their own without the other
* two riding along and clobbering a concurrent edit.
*/
export const updateRequirementSchema = z
.object({
fileType: z.enum(FILE_TYPE_VALUES, {
errorMap: () => ({ message: "Unknown document type" }),
}),
projectWorkstreamStageId: stageId,
required: z.boolean(),
namingRule,
})
.partial()
.refine((body) => Object.keys(body).length > 0, {
message: "Nothing to update",
});
export type UpdateRequirementBody = z.output<typeof updateRequirementSchema>;

View file

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

View file

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

View file

@ -0,0 +1,247 @@
/**
* PATCH (rename) / DELETE `.../workstreams/[workstreamId]/stages/[stageId]`
* (issue #411).
*
* Database mocked at the module boundary (`../queries` and the authz
* repository); no connection is opened. The blocked-delete path the one the
* acceptance criteria call out is exercised here end to end through the
* handler, with the rules themselves covered in `../ladder.test.ts`.
*/
import { describe, expect, it, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const {
mockGetServerSession,
mockLoadAuthzUser,
mockLoadProjectAuthzFacts,
mockFindLadder,
mockRenameStage,
mockDeleteStage,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockFindLadder: vi.fn(),
mockRenameStage: vi.fn(),
mockDeleteStage: 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", () => ({
findLadder: mockFindLadder,
renameStage: mockRenameStage,
deleteStage: mockDeleteStage,
}));
import { DELETE, PATCH } from "./route";
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
const NO_DEPENDENTS = {
workOrders: 0,
evidenceRequirements: 0,
isCurrentStage: false,
};
const LADDER = {
projectWorkstreamId: "9",
workstreamId: "1",
workstreamName: "Windows",
isDefault: false,
stages: [
{ id: "100", name: "Ordered", order: 0, dependents: NO_DEPENDENTS },
{
id: "101",
name: "Closed",
order: 1,
dependents: { ...NO_DEPENDENTS, workOrders: 4 },
},
],
};
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 = (stageId = "100") => ({
params: Promise.resolve({
projectId: "5",
// The catalogue `workstream.id`; the mocked ladder maps it to
// `project_workstream.id` 9, which is what the writes are asserted on.
workstreamId: "1",
stageId,
}),
});
const url = (stageId = "100") =>
`http://localhost/api/projects/5/workstreams/1/stages/${stageId}`;
function patchRequest(body: unknown, stageId = "100") {
return new NextRequest(url(stageId), {
method: "PATCH",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
mockFindLadder.mockResolvedValue(LADDER);
mockRenameStage.mockResolvedValue({ kind: "renamed" });
mockDeleteStage.mockResolvedValue({ kind: "deleted" });
});
describe("PATCH", () => {
beforeEach(() => {
signedInAs([CLIENT_ORG]);
projectExists();
});
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(401);
});
it("403s a caller who may view but not manage", async () => {
signedInAs([CONTRACTOR_ORG]);
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(403);
expect(mockRenameStage).not.toHaveBeenCalled();
});
it("renames a stage", async () => {
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ stageId: "100", renamed: true });
expect(mockRenameStage).toHaveBeenCalledWith(9n, 100n, "On site");
});
it("400s a patch with no name", async () => {
const res = await PATCH(patchRequest({}), params());
expect(res.status).toBe(400);
expect(mockRenameStage).not.toHaveBeenCalled();
});
it("ignores any attempt to set status, order or the dates", async () => {
// None of those columns is this issue's to write (ADR-0021): unknown keys
// are dropped by the schema, so they never reach the rename.
const res = await PATCH(
patchRequest({
name: "On site",
status: "complete",
order: 0,
startDate: "2026-09-03",
}),
params(),
);
expect(res.status).toBe(200);
expect(mockRenameStage).toHaveBeenCalledWith(9n, 100n, "On site");
});
it("404s a stage that is not in this workstream's ladder", async () => {
const res = await PATCH(patchRequest({ name: "On site" }, "999"), params("999"));
expect(res.status).toBe(404);
expect(mockRenameStage).not.toHaveBeenCalled();
});
it("404s a workstream of another project", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(404);
expect(mockRenameStage).not.toHaveBeenCalled();
});
it("passes a rejected name back as a 400 with its reason", async () => {
mockRenameStage.mockResolvedValue({
kind: "rejected",
reason: 'This workstream already has a "Closed" stage.',
});
const res = await PATCH(patchRequest({ name: "Closed" }), params());
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("already has");
});
});
describe("DELETE", () => {
beforeEach(() => {
signedInAs([CLIENT_ORG]);
projectExists();
});
it("403s a caller who may view but not manage", async () => {
signedInAs([CONTRACTOR_ORG]);
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(403);
expect(mockDeleteStage).not.toHaveBeenCalled();
});
it("deletes a stage", async () => {
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ stageId: "100", deleted: true });
expect(mockDeleteStage).toHaveBeenCalledWith(9n, 100n);
});
it("409s a blocked delete, flagged so the UI can tell it from a failure", async () => {
mockDeleteStage.mockResolvedValue({
kind: "blocked",
reason:
"4 work orders are in this stage. Move them to another stage before removing it.",
});
const res = await DELETE(new NextRequest(url("101")), params("101"));
expect(res.status).toBe(409);
expect(await res.json()).toEqual({
error:
"4 work orders are in this stage. Move them to another stage before removing it.",
blocked: true,
});
});
it("404s a stage that has already gone", async () => {
mockDeleteStage.mockResolvedValue({ kind: "not-found" });
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(404);
});
it("400s a non-numeric stage id", async () => {
const res = await DELETE(new NextRequest(url("abc")), params("abc"));
expect(res.status).toBe(400);
expect(mockDeleteStage).not.toHaveBeenCalled();
});
it("404s a workstream of another project without writing", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(404);
expect(mockDeleteStage).not.toHaveBeenCalled();
});
it("500s when the delete fails", async () => {
mockDeleteStage.mockRejectedValue(new Error("boom"));
vi.spyOn(console, "error").mockImplementation(() => {});
const res = await DELETE(new NextRequest(url()), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "Couldn't remove the stage" });
});
});

View file

@ -0,0 +1,143 @@
/**
* `.../workstreams/[workstreamId]/stages/[stageId]` (issue #411).
*
* PATCH rename a stage.
* DELETE remove a stage, subject to the ladder guardrails.
*
* The delete guardrails are the server's decision, always: the counts a
* browser is holding can be seconds out of date, and a Work order raised into
* a stage in the meantime must still block the delete. The client's job is to
* render the 409 it gets back, not to pre-empt it the same division of
* labour the workstream deselect uses (#410).
*
* Unlike that deselect there is no `?confirm=true`: every refusal here is a
* hard rule, not a warning, because nothing it protects belongs to this
* screen. See `../ladder.ts`.
*/
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { authorizeWorkstreamRequest } from "../../../authorize";
import { deleteStage, findLadder, renameStage } from "../queries";
type Params = {
params: Promise<{
projectId: string;
/** The catalogue `workstream.id` — see `../route.ts` and ADR-0021. */
workstreamId: string;
stageId: string;
}>;
};
/**
* The name is the only field of a stage a user owns: `order` moves through the
* collection route's reorder, and `status` / `start_date` / `due_date` are not
* this issue's to write (ADR-0021).
*/
const patchSchema = z.object({
name: z.string(),
});
function parseId(raw: string): bigint | null {
return /^\d+$/.test(raw) ? BigInt(raw) : null;
}
/** PATCH — rename one stage. */
export async function PATCH(req: NextRequest, props: Params) {
const { projectId, workstreamId: workstreamIdParam, stageId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseId(workstreamIdParam);
const id = parseId(stageId);
if (workstreamId === null || id === null) {
return NextResponse.json({ error: "Invalid stage" }, { status: 400 });
}
let body: z.infer<typeof patchSchema>;
try {
body = patchSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
// Scopes the stage to the project in the path.
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
if (!ladder.stages.some((stage) => stage.id === stageId)) {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
}
try {
const result = await renameStage(
BigInt(ladder.projectWorkstreamId),
id,
body.name,
);
if (result.kind === "not-found") {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
}
if (result.kind === "rejected") {
return NextResponse.json({ error: result.reason }, { status: 400 });
}
return NextResponse.json({ stageId, renamed: true });
} catch (err) {
console.error("PATCH .../stages/[stageId] failed:", err);
return NextResponse.json(
{ error: "Couldn't rename the stage" },
{ status: 500 },
);
}
}
/** DELETE — remove a stage if the guardrails allow it, then close the gap. */
export async function DELETE(_req: NextRequest, props: Params) {
const { projectId, workstreamId: workstreamIdParam, stageId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseId(workstreamIdParam);
const id = parseId(stageId);
if (workstreamId === null || id === null) {
return NextResponse.json({ error: "Invalid stage" }, { status: 400 });
}
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
try {
const result = await deleteStage(BigInt(ladder.projectWorkstreamId), id);
if (result.kind === "not-found") {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
}
// 409, and `blocked` alongside the message, so the grid can tell a refusal
// from a transport failure without parsing the sentence.
if (result.kind === "blocked") {
return NextResponse.json(
{ error: result.reason, blocked: true },
{ status: 409 },
);
}
return NextResponse.json({ stageId, deleted: true });
} catch (err) {
console.error("DELETE .../stages/[stageId] failed:", err);
return NextResponse.json(
{ error: "Couldn't remove the stage" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,193 @@
/**
* The stage-ladder rules (issue #411). Pure: no database, no mocks needed.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_LADDER,
MIN_STAGES,
NO_STAGE_DEPENDENTS,
checkReorder,
checkStageName,
contiguousOrders,
decideDeleteStage,
isDefaultLadder,
type StageDependents,
} from "./ladder";
const dependents = (patch: Partial<StageDependents> = {}): StageDependents => ({
...NO_STAGE_DEPENDENTS,
...patch,
});
describe("DEFAULT_LADDER", () => {
it("is the v1 ladder from CONTEXT.md, in order", () => {
expect([...DEFAULT_LADDER]).toEqual([
"Ordered",
"In progress",
"Completed",
"Charged",
"Closed",
]);
});
});
describe("decideDeleteStage", () => {
it("allows a delete from a ladder nothing points at", () => {
expect(decideDeleteStage(5, dependents())).toEqual({ kind: "allowed" });
});
it("blocks a delete that would leave fewer than two stages", () => {
const decision = decideDeleteStage(MIN_STAGES, dependents());
expect(decision.kind).toBe("blocked");
expect(decision.kind === "blocked" && decision.reason).toContain(
"at least 2 stages",
);
});
it("blocks a delete of a stage with work orders in it", () => {
const decision = decideDeleteStage(5, dependents({ workOrders: 3 }));
expect(decision).toEqual({
kind: "blocked",
reason:
"3 work orders are in this stage. Move them to another stage before removing it.",
});
});
it("says 'work order' in the singular for one", () => {
const decision = decideDeleteStage(5, dependents({ workOrders: 1 }));
expect(decision.kind === "blocked" && decision.reason).toBe(
"1 work order is in this stage. Move them to another stage before removing it.",
);
});
it("blocks a delete of a stage an evidence requirement points at", () => {
const decision = decideDeleteStage(
5,
dependents({ evidenceRequirements: 1 }),
);
expect(decision.kind === "blocked" && decision.reason).toContain(
"1 evidence requirement expects",
);
});
it("blocks a delete of the workstream's current stage", () => {
const decision = decideDeleteStage(5, dependents({ isCurrentStage: true }));
expect(decision.kind === "blocked" && decision.reason).toContain(
"current stage",
);
});
it("reports the minimum-stages rule ahead of what is in the stage", () => {
// Both rules apply; the ladder's shape is the simpler thing to fix.
const decision = decideDeleteStage(2, dependents({ workOrders: 4 }));
expect(decision.kind === "blocked" && decision.reason).toContain(
"at least 2 stages",
);
});
it("has no confirmation escape hatch — a block is final", () => {
// Guards the shape of the decision type: unlike the deselect cascade
// (#410) there is no `needs-confirmation` outcome to override.
expect(
decideDeleteStage(5, dependents({ workOrders: 1 })).kind,
).toBe("blocked");
});
});
describe("isDefaultLadder", () => {
it("recognises the seeded ladder", () => {
expect(isDefaultLadder([...DEFAULT_LADDER])).toBe(true);
});
it("ignores case and surrounding whitespace", () => {
expect(
isDefaultLadder(["ordered", " In progress", "COMPLETED", "Charged", "Closed"]),
).toBe(true);
});
it("is false once a stage is renamed", () => {
expect(
isDefaultLadder(["Ordered", "On site", "Completed", "Charged", "Closed"]),
).toBe(false);
});
it("is false once the ladder is reordered", () => {
expect(
isDefaultLadder(["In progress", "Ordered", "Completed", "Charged", "Closed"]),
).toBe(false);
});
it("is false when a stage is added or removed", () => {
expect(isDefaultLadder([...DEFAULT_LADDER, "Snagging"])).toBe(false);
expect(isDefaultLadder(DEFAULT_LADDER.slice(0, 4))).toBe(false);
});
it("is false for a workstream with no stages", () => {
expect(isDefaultLadder([])).toBe(false);
});
});
describe("checkStageName", () => {
it("trims and collapses whitespace", () => {
expect(checkStageName(" On site ", [])).toEqual({
ok: true,
name: "On site",
});
});
it("rejects a blank name", () => {
expect(checkStageName(" ", []).ok).toBe(false);
});
it("rejects a name longer than 60 characters", () => {
expect(checkStageName("x".repeat(61), []).ok).toBe(false);
expect(checkStageName("x".repeat(60), []).ok).toBe(true);
});
it("rejects a duplicate, ignoring case", () => {
const check = checkStageName("ordered", ["Ordered", "Closed"]);
expect(check.ok).toBe(false);
expect(!check.ok && check.error).toContain("already has");
});
it("lets a stage keep its own name on rename", () => {
// The caller passes the *other* stages' names, so this is not a clash.
expect(checkStageName("Ordered", ["Closed"]).ok).toBe(true);
});
});
describe("checkReorder", () => {
it("accepts a permutation of the ladder", () => {
expect(checkReorder(["1", "2", "3"], ["3", "1", "2"])).toEqual({
ok: true,
ids: ["3", "1", "2"],
});
});
it("rejects a list that is missing a stage", () => {
expect(checkReorder(["1", "2", "3"], ["1", "2"]).ok).toBe(false);
});
it("rejects a list naming an unknown stage", () => {
expect(checkReorder(["1", "2", "3"], ["1", "2", "9"]).ok).toBe(false);
});
it("rejects a list repeating a stage", () => {
expect(checkReorder(["1", "2", "3"], ["1", "1", "2"]).ok).toBe(false);
});
it("explains a mismatch as a stale list, not a bad request", () => {
const check = checkReorder(["1", "2"], ["1"]);
expect(!check.ok && check.error).toContain("Reload");
});
});
describe("contiguousOrders", () => {
it("numbers from 0 with no gaps", () => {
expect(contiguousOrders(["7", "4", "9"])).toEqual([
{ id: "7", order: 0 },
{ id: "4", order: 1 },
{ id: "9", order: 2 },
]);
});
});

View file

@ -0,0 +1,214 @@
/**
* Stage-ladder rules for a Project Workstream (issue #411).
*
* Deliberately **pure** no database client, no React so the route
* handlers, the unit tests and the browser bundle share one copy of the rules,
* the way `../../cascade.ts` does for the deselect cascade. Every count these
* functions decide from is loaded in `./queries`.
*
* The ladder is the *only* lifecycle mechanism in v1 (CONTEXT.md, **Stage**):
* a Work order's position is its stage FK, and the terminal stage is simply
* the one with the highest `order`. That is why the guardrails here are strict
* a ladder that loses a stage under a Work order, or whose `order` values
* develop a hole, is a workflow that has silently changed shape.
*/
/**
* The ladder every Project Workstream starts with, in order seeded once,
* then owned by the user (ADR-0021).
*
* The wireframe's "New / Active / Active / Active / Complete" is placeholder
* junk and is deliberately not what we seed; this is the v1 default named in
* CONTEXT.md and issue #411.
*/
export const DEFAULT_LADDER = [
"Ordered",
"In progress",
"Completed",
"Charged",
"Closed",
] as const;
/**
* A ladder with one stage is not a workflow nothing can move. Two is the
* smallest ladder with a start and a terminal stage.
*/
export const MIN_STAGES = 2;
/** Long enough for "Awaiting client sign-off", short enough to render. */
export const MAX_STAGE_NAME_LENGTH = 60;
/** What already points at one stage, and therefore what a delete would break. */
export interface StageDependents {
/** `work_order.project_workstream_stage_id` rows sitting in this stage. */
workOrders: number;
/** Evidence requirements (#412) narrowed to this stage. */
evidenceRequirements: number;
/** Whether `project_workstream.current_stage_id` points here. */
isCurrentStage: boolean;
}
/** A stage nothing points at. */
export const NO_STAGE_DEPENDENTS: StageDependents = {
workOrders: 0,
evidenceRequirements: 0,
isCurrentStage: false,
};
export type DeleteStageDecision =
| { kind: "blocked"; reason: string }
| { kind: "allowed" };
/**
* Whether a stage may be deleted from a ladder of `stageCount` stages.
*
* Unlike the workstream deselect, there is no `confirm` escape hatch: each of
* these is a hard rule, because none of what they protect is this screen's to
* throw away. Work orders and evidence requirements are other features' rows,
* `current_stage_id` is a column #411 never writes (ADR-0021), and a
* one-stage ladder is not a workflow.
*
* Checked shape-first (is the ladder still a ladder afterwards?) then
* contents, so the reason a user sees names the simplest thing that is wrong.
*/
export function decideDeleteStage(
stageCount: number,
dependents: StageDependents,
): DeleteStageDecision {
if (stageCount <= MIN_STAGES) {
return {
kind: "blocked",
reason:
`A workstream needs at least ${MIN_STAGES} stages. ` +
`Add another stage before removing this one.`,
};
}
if (dependents.workOrders > 0) {
const plural = dependents.workOrders === 1 ? "work order" : "work orders";
return {
kind: "blocked",
reason:
`${dependents.workOrders} ${plural} ${dependents.workOrders === 1 ? "is" : "are"} in this stage. ` +
`Move them to another stage before removing it.`,
};
}
if (dependents.evidenceRequirements > 0) {
const n = dependents.evidenceRequirements;
return {
kind: "blocked",
reason:
`${n} evidence ${n === 1 ? "requirement expects" : "requirements expect"} a document at this stage. ` +
`Remove ${n === 1 ? "it" : "them"} first.`,
};
}
if (dependents.isCurrentStage) {
return {
kind: "blocked",
reason:
"This stage is the workstream's current stage, so it can't be removed.",
};
}
return { kind: "allowed" };
}
/**
* Whether a ladder is still the seeded default same names, same order.
*
* Drives the "Default / Custom" badge on the overrides panel. Compared
* case-insensitively on trimmed names, so re-typing "ordered" over "Ordered"
* does not read as an override the user did not make.
*/
export function isDefaultLadder(names: readonly string[]): boolean {
if (names.length !== DEFAULT_LADDER.length) return false;
return names.every(
(name, i) =>
name.trim().toLowerCase() === DEFAULT_LADDER[i].trim().toLowerCase(),
);
}
export type StageNameCheck =
| { ok: true; name: string }
| { ok: false; error: string };
/**
* Validate and normalise a stage name against the names already in the ladder.
*
* `otherNames` is every *other* stage's name a rename must exclude the stage
* being renamed, or renaming "Charged" to "Charged" would collide with itself.
* Duplicates are rejected case-insensitively: two stages a user cannot tell
* apart make the ladder unreadable, and stage names are how work orders are
* talked about.
*/
export function checkStageName(
raw: string,
otherNames: readonly string[],
): StageNameCheck {
const name = raw.trim().replace(/\s+/g, " ");
if (name.length === 0) {
return { ok: false, error: "Give the stage a name." };
}
if (name.length > MAX_STAGE_NAME_LENGTH) {
return {
ok: false,
error: `Stage names can be at most ${MAX_STAGE_NAME_LENGTH} characters.`,
};
}
const clash = otherNames.some(
(other) => other.trim().toLowerCase() === name.toLowerCase(),
);
if (clash) {
return { ok: false, error: `This workstream already has a "${name}" stage.` };
}
return { ok: true, name };
}
export type ReorderCheck =
| { ok: true; ids: string[] }
| { ok: false; error: string };
/**
* Check that a requested order is a permutation of the ladder it claims to
* reorder same ids, each exactly once.
*
* A reorder that dropped or invented an id would renumber a partial ladder and
* leave a hole in `order`, so the request is refused rather than repaired. The
* caller compares against ids re-read inside the same transaction, which is
* also what makes this the stale-client check: a ladder edited in another tab
* no longer matches, so the reorder is rejected instead of applied to a shape
* the user was not looking at.
*/
export function checkReorder(
currentIds: readonly string[],
requestedIds: readonly string[],
): ReorderCheck {
const stale = {
ok: false as const,
error: "The stage list has changed. Reload and try again.",
};
if (requestedIds.length !== currentIds.length) return stale;
if (new Set(requestedIds).size !== requestedIds.length) return stale;
const current = new Set(currentIds);
if (!requestedIds.every((id) => current.has(id))) return stale;
return { ok: true, ids: [...requestedIds] };
}
/**
* The `order` each id should carry: contiguous from 0, in the given sequence.
*
* Every write path that changes the shape of a ladder add, delete, reorder
* ends here, so `order` is contiguous by construction rather than by repair.
*/
export function contiguousOrders(
ids: readonly string[],
): Array<{ id: string; order: number }> {
return ids.map((id, order) => ({ id, order }));
}

View file

@ -0,0 +1,517 @@
/**
* Persistence for the stage-configuration setup screen (issue #411).
*
* The route handlers and the server-rendered page both read through here, so
* the two always agree on shape the page paints first, the handlers serve
* every refetch after it. Same split as `../../queries.ts` for #410.
*
* Several columns are never written from this module, by decision (ADR-0021):
* - `project_workstream.current_stage_id` deferred to #426; v1 derives a
* workstream's position from its Work orders' stage FKs.
* - `work_order.status` the stage FK is the only progress mechanism.
* - `project_workstream_stage.start_date` / `due_date` dates describe a
* work order's delivery, not a rung of a workflow, so a ladder stage has
* no date to hold. The columns stay nullable and stay NULL; whether they
* survive is a schema question for later.
*
* `project_workstream_stage.status` is the awkward case: it is
* `NOT NULL` with no database default, so a row cannot be inserted without a
* value. Inserts therefore write the placeholder constant below, once, and no
* update path ever touches the column again. See ADR-0021.
*/
import { db } from "@/app/db/db";
import { and, eq, inArray, sql } from "drizzle-orm";
import {
projectWorkstream,
projectWorkstreamEvidenceRequirement,
projectWorkstreamStage,
workOrder,
workstream,
} from "@/app/db/schema/projects/projects";
import {
DEFAULT_LADDER,
checkReorder,
checkStageName,
contiguousOrders,
decideDeleteStage,
isDefaultLadder,
type StageDependents,
} from "./ladder";
/**
* The value written into the `NOT NULL` `status` column on insert.
*
* Not a lifecycle state and not read by anything the stage's meaning is its
* position in the ladder. Matches what `seed-projects-dashboard.ts` already
* writes, so existing rows and new ones agree. When #426 gives the column a
* real value set, that issue owns the backfill.
*/
export const PLACEHOLDER_STAGE_STATUS = "active";
/** One stage as the API and the page render it. */
export interface LadderStage {
/** `project_workstream_stage.id`, serialised — bigints do not survive JSON. */
id: string;
name: string;
/** Contiguous from 0 within its ladder. */
order: number;
/** What points at this stage — drives the delete guardrails' explanations. */
dependents: StageDependents;
}
/** One Project Workstream's whole ladder. */
export interface WorkstreamLadder {
/** `project_workstream.id` — the id the CRUD routes are keyed on. */
projectWorkstreamId: string;
/** `workstream.id`, the reference-data row. */
workstreamId: string;
workstreamName: string;
stages: LadderStage[];
/** True while the ladder is still the seeded default, names and order. */
isDefault: boolean;
}
/** One row of the stage read, before it is serialised for the wire. */
type StageRow = {
id: bigint;
projectWorkstreamId: bigint;
name: string;
order: number;
workOrders: number;
evidenceRequirements: number;
isCurrentStage: boolean;
};
/**
* Every stage of the Project Workstreams matching `where`, with the counts the
* delete guardrails need.
*
* One round trip: the two child tables are left-joined and counted with
* `count(distinct)`, so the join fan-out between them does not inflate either
* number. `current_stage_id` is read (never written) to spot the stage a
* workstream points at.
*/
async function loadStageRows(
where: ReturnType<typeof and>,
): Promise<StageRow[]> {
return db
.select({
id: projectWorkstreamStage.id,
projectWorkstreamId: projectWorkstreamStage.projectWorkstreamId,
name: projectWorkstreamStage.name,
order: projectWorkstreamStage.order,
workOrders: sql<number>`count(distinct ${workOrder.id})::int`,
evidenceRequirements: sql<number>`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`,
isCurrentStage: sql<boolean>`coalesce(bool_or(${projectWorkstream.currentStageId} = ${projectWorkstreamStage.id}), false)`,
})
.from(projectWorkstreamStage)
.innerJoin(
projectWorkstream,
eq(projectWorkstream.id, projectWorkstreamStage.projectWorkstreamId),
)
.leftJoin(
workOrder,
eq(workOrder.projectWorkstreamStageId, projectWorkstreamStage.id),
)
.leftJoin(
projectWorkstreamEvidenceRequirement,
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
projectWorkstreamStage.id,
),
)
.where(where)
.groupBy(projectWorkstreamStage.id)
.orderBy(projectWorkstreamStage.order, projectWorkstreamStage.id);
}
function toLadderStage(row: StageRow): LadderStage {
return {
id: row.id.toString(),
name: row.name,
order: row.order,
dependents: {
workOrders: row.workOrders,
evidenceRequirements: row.evidenceRequirements,
isCurrentStage: row.isCurrentStage,
},
};
}
function toLadder(
workstreamRow: {
projectWorkstreamId: bigint;
workstreamId: bigint;
workstreamName: string;
},
rows: StageRow[],
): WorkstreamLadder {
const stages = rows.map(toLadderStage);
return {
projectWorkstreamId: workstreamRow.projectWorkstreamId.toString(),
workstreamId: workstreamRow.workstreamId.toString(),
workstreamName: workstreamRow.workstreamName,
stages,
isDefault: isDefaultLadder(stages.map((s) => s.name)),
};
}
/**
* The Project Workstreams a project has selected, catalogue name included.
*
* `workstreamId` narrows to one, and is the **catalogue** `workstream.id`
* see `findLadder` for why the routes are keyed on that rather than on
* `project_workstream.id`.
*/
async function loadSelectedWorkstreams(
projectId: bigint,
workstreamId?: bigint,
) {
return db
.select({
projectWorkstreamId: projectWorkstream.id,
workstreamId: projectWorkstream.workstreamId,
workstreamName: workstream.name,
})
.from(projectWorkstream)
.innerJoin(workstream, eq(workstream.id, projectWorkstream.workstreamId))
.where(
workstreamId === undefined
? eq(projectWorkstream.projectId, projectId)
: and(
eq(projectWorkstream.projectId, projectId),
eq(projectWorkstream.workstreamId, workstreamId),
),
)
.orderBy(projectWorkstream.id);
}
/**
* Every selected workstream's ladder for one project what the setup screen
* renders on first paint.
*
* A workstream with no stages comes back with an empty `stages` array rather
* than being omitted: that is precisely the state the import readiness gate
* (#414) reports as incomplete, so the screen must show it.
*/
export async function listProjectLadders(
projectId: bigint,
): Promise<WorkstreamLadder[]> {
const workstreams = await loadSelectedWorkstreams(projectId);
if (workstreams.length === 0) return [];
const rows = await loadStageRows(eq(projectWorkstream.projectId, projectId));
const byWorkstream = new Map<string, StageRow[]>();
for (const row of rows) {
const key = row.projectWorkstreamId.toString();
const bucket = byWorkstream.get(key);
if (bucket) bucket.push(row);
else byWorkstream.set(key, [row]);
}
return workstreams.map((w) =>
toLadder(w, byWorkstream.get(w.projectWorkstreamId.toString()) ?? []),
);
}
/**
* One workstream's ladder on one project, or null when that project has not
* selected it.
*
* **Keyed on `workstream.id`**, the catalogue row not on
* `project_workstream.id`. The stages routes are nested under the same
* `/workstreams/[workstreamId]` segment as the deselect endpoint (#410), and a
* URL segment must mean one thing: `/workstreams/9` and `/workstreams/9/stages`
* name the same workstream. See ADR-0021.
*
* The null is load-bearing: it is how every route handler here checks that the
* workstream in the path is selected on the project in the path, so an id from
* another project reads as 404 rather than being edited. The returned
* `projectWorkstreamId` is what the write functions below take, so the
* translation happens exactly once per request, at the edge.
*/
export async function findLadder(
projectId: bigint,
workstreamId: bigint,
): Promise<WorkstreamLadder | null> {
const [workstreamRow] = await loadSelectedWorkstreams(projectId, workstreamId);
if (!workstreamRow) return null;
const rows = await loadStageRows(
eq(
projectWorkstreamStage.projectWorkstreamId,
workstreamRow.projectWorkstreamId,
),
);
return toLadder(workstreamRow, rows);
}
/**
* Seed the default ladder for every workstream of `projectId` that has none.
*
* Idempotent, and idempotent under concurrency: the workstream rows are locked
* `FOR UPDATE` before their stage counts are read, so two simultaneous visits
* to the setup screen cannot both find "no stages" and both insert. The second
* transaction blocks, re-reads, and finds the ladder already there. That lock
* is what makes "seeded exactly once per workstream" true rather than likely.
*
* Returns the ids seeded, so the caller can tell a first visit from a revisit.
*/
export async function seedDefaultLadders(
projectId: bigint,
): Promise<string[]> {
return db.transaction(async (tx) => {
const workstreams = await tx
.select({ id: projectWorkstream.id })
.from(projectWorkstream)
.where(eq(projectWorkstream.projectId, projectId))
.for("update");
if (workstreams.length === 0) return [];
const withStages = await tx
.select({ id: projectWorkstreamStage.projectWorkstreamId })
.from(projectWorkstreamStage)
.where(
inArray(
projectWorkstreamStage.projectWorkstreamId,
workstreams.map((w) => w.id),
),
)
.groupBy(projectWorkstreamStage.projectWorkstreamId);
const seeded = new Set(withStages.map((row) => row.id.toString()));
const empty = workstreams.filter((w) => !seeded.has(w.id.toString()));
if (empty.length === 0) return [];
await tx.insert(projectWorkstreamStage).values(
empty.flatMap((w) =>
DEFAULT_LADDER.map((name, order) => ({
projectWorkstreamId: w.id,
name,
order,
status: PLACEHOLDER_STAGE_STATUS,
})),
),
);
return empty.map((w) => w.id.toString());
});
}
/** The stages of one workstream, locked for the rest of the transaction. */
async function lockedStages(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
projectWorkstreamId: bigint,
) {
return tx
.select({
id: projectWorkstreamStage.id,
name: projectWorkstreamStage.name,
order: projectWorkstreamStage.order,
})
.from(projectWorkstreamStage)
.where(eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId))
.orderBy(projectWorkstreamStage.order, projectWorkstreamStage.id)
.for("update");
}
/** Rewrite `order` so the given sequence is contiguous from 0. */
async function writeOrders(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
ids: string[],
): Promise<void> {
for (const { id, order } of contiguousOrders(ids)) {
await tx
.update(projectWorkstreamStage)
.set({ order })
.where(eq(projectWorkstreamStage.id, BigInt(id)));
}
}
export type AddStageResult =
| { kind: "added"; stageId: string }
| { kind: "rejected"; reason: string };
/**
* Append a stage to the end of a ladder.
*
* New stages land last because that is the only position that cannot change
* what the existing ladder means inserting in the middle would silently
* renumber stages that Work orders already sit in. Reordering afterwards is
* one click away, and is an explicit act.
*
* The name is re-checked against names re-read inside the transaction, so two
* tabs adding "Snagging" at once cannot both succeed.
*/
export async function addStage(
projectWorkstreamId: bigint,
stage: { name: string },
): Promise<AddStageResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
const check = checkStageName(
stage.name,
existing.map((s) => s.name),
);
if (!check.ok) return { kind: "rejected", reason: check.error };
const [inserted] = await tx
.insert(projectWorkstreamStage)
.values({
projectWorkstreamId,
name: check.name,
order: existing.length,
status: PLACEHOLDER_STAGE_STATUS,
})
.returning({ id: projectWorkstreamStage.id });
// The append is only contiguous if the ladder already was; a ladder that
// arrived here with a hole is straightened rather than extended crooked.
await writeOrders(tx, [
...existing.map((s) => s.id.toString()),
inserted.id.toString(),
]);
return { kind: "added", stageId: inserted.id.toString() };
});
}
export type RenameStageResult =
| { kind: "renamed" }
| { kind: "not-found" }
| { kind: "rejected"; reason: string };
/**
* Rename a stage.
*
* The name is the only field of a stage a user owns: `order` changes go
* through `reorderStages`, and `status`, `status_description`, `start_date`
* and `due_date` are not this issue's to write (ADR-0021). The uniqueness
* check runs against names re-read under the lock, excluding this stage so a
* stage may always keep its own name.
*/
export async function renameStage(
projectWorkstreamId: bigint,
stageId: bigint,
name: string,
): Promise<RenameStageResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
if (!existing.some((s) => s.id === stageId)) return { kind: "not-found" };
const check = checkStageName(
name,
existing.filter((s) => s.id !== stageId).map((s) => s.name),
);
if (!check.ok) return { kind: "rejected", reason: check.error };
await tx
.update(projectWorkstreamStage)
.set({ name: check.name })
.where(eq(projectWorkstreamStage.id, stageId));
return { kind: "renamed" };
});
}
export type DeleteStageResult =
| { kind: "deleted" }
| { kind: "not-found" }
| { kind: "blocked"; reason: string };
/**
* Delete a stage, then close the gap its `order` left behind.
*
* The guardrails are evaluated against counts re-read **inside** the
* transaction with the ladder locked, not against whatever the client last
* saw: a Work order raised into this stage a second ago must block the delete,
* and the browser cannot know that. `decideDeleteStage` makes the decision;
* this function only gathers the facts and applies the outcome.
*/
export async function deleteStage(
projectWorkstreamId: bigint,
stageId: bigint,
): Promise<DeleteStageResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
if (!existing.some((s) => s.id === stageId)) return { kind: "not-found" };
const [counts] = await tx
.select({
workOrders: sql<number>`count(distinct ${workOrder.id})::int`,
evidenceRequirements: sql<number>`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`,
isCurrentStage: sql<boolean>`coalesce(bool_or(${projectWorkstream.currentStageId} = ${projectWorkstreamStage.id}), false)`,
})
.from(projectWorkstreamStage)
.innerJoin(
projectWorkstream,
eq(projectWorkstream.id, projectWorkstreamStage.projectWorkstreamId),
)
.leftJoin(
workOrder,
eq(workOrder.projectWorkstreamStageId, projectWorkstreamStage.id),
)
.leftJoin(
projectWorkstreamEvidenceRequirement,
eq(
projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
projectWorkstreamStage.id,
),
)
.where(eq(projectWorkstreamStage.id, stageId))
.groupBy(projectWorkstreamStage.id);
const decision = decideDeleteStage(existing.length, {
workOrders: counts?.workOrders ?? 0,
evidenceRequirements: counts?.evidenceRequirements ?? 0,
isCurrentStage: counts?.isCurrentStage ?? false,
});
if (decision.kind === "blocked") {
return { kind: "blocked", reason: decision.reason };
}
await tx
.delete(projectWorkstreamStage)
.where(eq(projectWorkstreamStage.id, stageId));
await writeOrders(
tx,
existing.filter((s) => s.id !== stageId).map((s) => s.id.toString()),
);
return { kind: "deleted" };
});
}
export type ReorderStagesResult =
| { kind: "reordered" }
| { kind: "rejected"; reason: string };
/**
* Rewrite a ladder's `order` to the sequence the client asked for.
*
* The request must name every stage of the ladder exactly once; a request that
* does not is refused as stale rather than applied to a shape the user was not
* looking at. The comparison is against ids re-read under the lock, which is
* what makes the staleness check meaningful.
*/
export async function reorderStages(
projectWorkstreamId: bigint,
requestedIds: string[],
): Promise<ReorderStagesResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
const check = checkReorder(
existing.map((s) => s.id.toString()),
requestedIds,
);
if (!check.ok) return { kind: "rejected", reason: check.error };
await writeOrders(tx, check.ids);
return { kind: "reordered" };
});
}

View file

@ -0,0 +1,277 @@
/**
* GET / POST / PATCH `.../workstreams/[workstreamId]/stages` (issue #411).
*
* The id in the path is the catalogue `workstream.id`; `findLadder` translates
* it to the `project_workstream.id` the writes take (ADR-0021), so the mocked
* ladder below is what decides which workstream is written to.
*
* 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,
mockFindLadder,
mockAddStage,
mockReorderStages,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockFindLadder: vi.fn(),
mockAddStage: vi.fn(),
mockReorderStages: 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", () => ({
findLadder: mockFindLadder,
addStage: mockAddStage,
reorderStages: mockReorderStages,
}));
import { GET, PATCH, 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 NO_DEPENDENTS = {
workOrders: 0,
evidenceRequirements: 0,
isCurrentStage: false,
};
const LADDER = {
projectWorkstreamId: "9",
workstreamId: "1",
workstreamName: "Windows",
isDefault: true,
stages: [
{ id: "100", name: "Ordered", order: 0, dependents: NO_DEPENDENTS },
{ id: "101", name: "Closed", order: 1, dependents: NO_DEPENDENTS },
],
};
function signedInAs(organisationIds: string[]) {
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
mockLoadAuthzUser.mockResolvedValue({
id: 7n,
email: "someone@landlord.example",
organisationIds,
});
}
function projectExists() {
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: false,
contractorOrganisationIds: [CONTRACTOR_ORG],
});
}
const params = (projectId = "5", workstreamId = "1") => ({
params: Promise.resolve({ projectId, workstreamId }),
});
const url = (projectId = "5", workstreamId = "1") =>
`http://localhost/api/projects/${projectId}/workstreams/${workstreamId}/stages`;
function jsonRequest(method: string, body: unknown) {
return new NextRequest(url(), {
method,
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
mockFindLadder.mockResolvedValue(LADDER);
mockAddStage.mockResolvedValue({ kind: "added", stageId: "102" });
mockReorderStages.mockResolvedValue({ kind: "reordered" });
});
describe("GET", () => {
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorised" });
});
it("404s a project the caller may not see, rather than 403", async () => {
signedInAs([OTHER_ORG]);
projectExists();
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(404);
expect(mockFindLadder).not.toHaveBeenCalled();
});
it("400s a non-numeric workstream id", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(new NextRequest(url("5", "abc")), params("5", "abc"));
expect(res.status).toBe(400);
});
it("404s a workstream that is not selected on this project", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockFindLadder.mockResolvedValue(null);
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(404);
});
it("returns the ladder and whether the caller may edit it", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ladder: LADDER, canManage: true });
expect(mockFindLadder).toHaveBeenCalledWith(5n, 1n);
});
it("lets a contractor read the ladder but not manage it", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await GET(new NextRequest(url()), params());
expect(res.status).toBe(200);
expect((await res.json()).canManage).toBe(false);
});
});
describe("POST", () => {
beforeEach(() => {
signedInAs([CLIENT_ORG]);
projectExists();
});
it("403s a caller who may view but not manage", async () => {
signedInAs([CONTRACTOR_ORG]);
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(403);
expect(mockAddStage).not.toHaveBeenCalled();
});
it("adds a stage", async () => {
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ stageId: "102" });
expect(mockAddStage).toHaveBeenCalledWith(9n, { name: "Snagging" });
});
it("ignores dates on the way in — a stage has none (ADR-0021)", async () => {
const res = await POST(
jsonRequest("POST", {
name: "Snagging",
startDate: "2026-09-03",
dueDate: "2026-12-01",
}),
params(),
);
expect(res.status).toBe(201);
expect(mockAddStage).toHaveBeenCalledWith(9n, { name: "Snagging" });
});
it("400s a body with no name", async () => {
const res = await POST(jsonRequest("POST", {}), params());
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid body" });
});
it("passes a rejected name back as a 400 with its reason", async () => {
mockAddStage.mockResolvedValue({
kind: "rejected",
reason: 'This workstream already has a "Ordered" stage.',
});
const res = await POST(jsonRequest("POST", { name: "Ordered" }), params());
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("already has");
});
it("404s a workstream of another project without writing", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(404);
expect(mockAddStage).not.toHaveBeenCalled();
});
it("500s when the write fails", async () => {
mockAddStage.mockRejectedValue(new Error("boom"));
vi.spyOn(console, "error").mockImplementation(() => {});
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "Couldn't add the stage" });
});
});
describe("PATCH (reorder)", () => {
beforeEach(() => {
signedInAs([CLIENT_ORG]);
projectExists();
});
it("403s a caller who may not manage", async () => {
signedInAs([CONTRACTOR_ORG]);
const res = await PATCH(
jsonRequest("PATCH", { stageIds: ["101", "100"] }),
params(),
);
expect(res.status).toBe(403);
expect(mockReorderStages).not.toHaveBeenCalled();
});
it("reorders the ladder", async () => {
const res = await PATCH(
jsonRequest("PATCH", { stageIds: ["101", "100"] }),
params(),
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ reordered: true });
expect(mockReorderStages).toHaveBeenCalledWith(9n, ["101", "100"]);
});
it("409s a stale list rather than applying it", async () => {
mockReorderStages.mockResolvedValue({
kind: "rejected",
reason: "The stage list has changed. Reload and try again.",
});
const res = await PATCH(
jsonRequest("PATCH", { stageIds: ["101"] }),
params(),
);
expect(res.status).toBe(409);
expect((await res.json()).error).toContain("Reload");
});
it("400s a body that is not a list of ids", async () => {
const res = await PATCH(
jsonRequest("PATCH", { stageIds: [100, 101] }),
params(),
);
expect(res.status).toBe(400);
expect(mockReorderStages).not.toHaveBeenCalled();
});
it("404s a workstream of another project without writing", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await PATCH(
jsonRequest("PATCH", { stageIds: ["101", "100"] }),
params(),
);
expect(res.status).toBe(404);
expect(mockReorderStages).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,175 @@
/**
* `/api/projects/[projectId]/workstreams/[workstreamId]/stages` (issue #411).
*
* GET one workstream's ladder, stages in `order`, with the counts that
* explain why a stage may not be deletable.
* POST add a stage to the end of the ladder.
* PATCH reorder the whole ladder (`{ stageIds }`, every id exactly once).
*
* Rename and delete are keyed on a single stage and live on
* `./[stageId]/route.ts`.
*
* Keyed on `workstream.id` the catalogue row because these routes are
* nested under the same `/workstreams/[workstreamId]` segment as the deselect
* endpoint (#410), and one segment must mean one thing. Next.js enforces the
* shared slug name; the shared *meaning* is ours to keep. `findLadder`
* translates to the `project_workstream.id` the writes take, once, and returns
* null for a workstream this project has not selected which is how a
* cross-project id becomes a 404 rather than an edit. See ADR-0021.
*
* Authorization is `../../authorize` unchanged (#410): view to read, manage to
* write, and a project the caller cannot see reads as 404 rather than 403.
*/
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { authorizeWorkstreamRequest } from "../../authorize";
import { addStage, findLadder, reorderStages } from "./queries";
type Params = {
params: Promise<{ projectId: string; workstreamId: string }>;
};
/**
* A stage is a name and a position, and the position is decided by where it is
* added so a name is the whole body. The `start_date` / `due_date` columns
* exist on the table but describe a work order's delivery rather than a rung
* of a workflow; they are deliberately left NULL (ADR-0021), so there is
* nothing here to set them with.
*/
const addSchema = z.object({
name: z.string(),
});
const reorderSchema = z.object({
/** Every stage id of this ladder, in the order they should end up in. */
stageIds: z.array(z.string().regex(/^\d+$/, "Stage ids must be numeric")),
});
/** Parses `[workstreamId]`; null for anything that is not a bare id. */
function parseWorkstreamId(raw: string): bigint | null {
return /^\d+$/.test(raw) ? BigInt(raw) : null;
}
/** GET — the ladder, re-read from the database on every request. */
export async function GET(_req: NextRequest, props: Params) {
const { projectId, workstreamId: workstreamIdParam } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "view");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseWorkstreamId(workstreamIdParam);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
return NextResponse.json({ ladder, canManage: auth.canManage });
}
/** POST — append a stage. */
export async function POST(req: NextRequest, props: Params) {
const { projectId, workstreamId: workstreamIdParam } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseWorkstreamId(workstreamIdParam);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
let body: z.infer<typeof addSchema>;
try {
body = addSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
// Existence and ownership before the write, and the translation from the
// catalogue id in the path to the `project_workstream.id` the write takes.
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
try {
const result = await addStage(BigInt(ladder.projectWorkstreamId), {
name: body.name,
});
if (result.kind === "rejected") {
return NextResponse.json({ error: result.reason }, { status: 400 });
}
return NextResponse.json({ stageId: result.stageId }, { status: 201 });
} catch (err) {
console.error("POST .../workstreams/[workstreamId]/stages failed:", err);
return NextResponse.json(
{ error: "Couldn't add the stage" },
{ status: 500 },
);
}
}
/**
* PATCH reorder the ladder.
*
* The whole sequence is sent, not a "move up" instruction, so the server never
* has to guess what the client was looking at: a list that no longer matches
* the ladder is rejected as stale (409) rather than partially applied.
*/
export async function PATCH(req: NextRequest, props: Params) {
const { projectId, workstreamId: workstreamIdParam } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const workstreamId = parseWorkstreamId(workstreamIdParam);
if (workstreamId === null) {
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
}
let body: z.infer<typeof reorderSchema>;
try {
body = reorderSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
{ error: "Workstream is not selected on this project" },
{ status: 404 },
);
}
try {
const result = await reorderStages(
BigInt(ladder.projectWorkstreamId),
body.stageIds,
);
if (result.kind === "rejected") {
return NextResponse.json({ error: result.reason }, { status: 409 });
}
return NextResponse.json({ reordered: true });
} catch (err) {
console.error("PATCH .../workstreams/[workstreamId]/stages failed:", err);
return NextResponse.json(
{ error: "Couldn't reorder the stages" },
{ status: 500 },
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,228 @@
/**
* GET / POST `/api/projects/[projectId]/workstreams` (issue #410).
*
* The database is mocked at the module boundary (`./queries` and the authz
* repository); no connection is opened. The authorization *decisions* are the
* real ones from `@/lib/projects/authz` only the facts they read are faked.
*/
import { describe, expect, it, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const {
mockGetServerSession,
mockLoadAuthzUser,
mockLoadProjectAuthzFacts,
mockListWorkstreamOptions,
mockSelectWorkstream,
mockWorkstreamExists,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockListWorkstreamOptions: vi.fn(),
mockSelectWorkstream: vi.fn(),
mockWorkstreamExists: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("@/app/repositories/projects/authzRepository", () => ({
loadAuthzUser: mockLoadAuthzUser,
loadProjectAuthzFacts: mockLoadProjectAuthzFacts,
}));
vi.mock("./queries", () => ({
listWorkstreamOptions: mockListWorkstreamOptions,
selectWorkstream: mockSelectWorkstream,
workstreamExists: mockWorkstreamExists,
}));
import { GET, POST } from "./route";
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
const OTHER_ORG = "33333333-3333-3333-3333-333333333333";
const CATALOGUE = [
{
id: "1",
name: "Windows",
description: "Replacement and repair of windows.",
selected: true,
projectWorkstreamId: "9",
dependents: {
stages: 5,
evidenceRequirements: 0,
contractors: 0,
workOrders: 0,
},
},
{
id: "2",
name: "Doors",
description: "Replacement and repair of doors.",
selected: false,
projectWorkstreamId: null,
dependents: {
stages: 0,
evidenceRequirements: 0,
contractors: 0,
workOrders: 0,
},
},
];
function signedInAs(organisationIds: string[]) {
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
mockLoadAuthzUser.mockResolvedValue({
id: 7n,
email: "someone@landlord.example",
organisationIds,
});
}
function projectExists() {
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: false,
contractorOrganisationIds: [CONTRACTOR_ORG],
});
}
const params = (projectId = "5") => ({ params: Promise.resolve({ projectId }) });
function getRequest(projectId = "5") {
return new NextRequest(
`http://localhost/api/projects/${projectId}/workstreams`,
);
}
function postRequest(body: unknown, projectId = "5") {
return new NextRequest(
`http://localhost/api/projects/${projectId}/workstreams`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
}
beforeEach(() => {
vi.clearAllMocks();
mockListWorkstreamOptions.mockResolvedValue(CATALOGUE);
mockWorkstreamExists.mockResolvedValue(true);
mockSelectWorkstream.mockResolvedValue({ id: 42n, created: true });
});
describe("GET", () => {
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await GET(getRequest(), params());
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorised" });
});
it("400s on a non-numeric project id", async () => {
signedInAs([CLIENT_ORG]);
const res = await GET(getRequest("abc"), params("abc"));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid project" });
});
it("404s when the project does not exist", async () => {
signedInAs([CLIENT_ORG]);
mockLoadProjectAuthzFacts.mockResolvedValue(null);
const res = await GET(getRequest(), params());
expect(res.status).toBe(404);
});
it("404s rather than 403s for a user outside the project", async () => {
signedInAs([OTHER_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(404);
expect(mockListWorkstreamOptions).not.toHaveBeenCalled();
});
it("returns the catalogue with the project's selections", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
workstreams: CATALOGUE,
canManage: true,
});
expect(mockListWorkstreamOptions).toHaveBeenCalledWith(5n);
});
it("lets a contractor read but marks the grid unmanageable", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await GET(getRequest(), params());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ canManage: false });
});
});
describe("POST", () => {
it("403s for a contractor, who may see setup but not edit it", async () => {
signedInAs([CONTRACTOR_ORG]);
projectExists();
const res = await POST(postRequest({ workstreamId: "2" }), params());
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: "Forbidden" });
expect(mockSelectWorkstream).not.toHaveBeenCalled();
});
it("400s on a body without a numeric workstreamId", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await POST(postRequest({ workstreamId: "windows" }), params());
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid body" });
});
it("404s when the workstream is not in the catalogue", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockWorkstreamExists.mockResolvedValue(false);
const res = await POST(postRequest({ workstreamId: "99" }), params());
expect(res.status).toBe(404);
expect(mockSelectWorkstream).not.toHaveBeenCalled();
});
it("201s on a new selection", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await POST(postRequest({ workstreamId: "2" }), params());
expect(res.status).toBe(201);
expect(await res.json()).toEqual({
projectWorkstreamId: "42",
workstreamId: "2",
selected: true,
});
expect(mockSelectWorkstream).toHaveBeenCalledWith(5n, 2n);
});
it("200s when the workstream was already selected", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockSelectWorkstream.mockResolvedValue({ id: 9n, created: false });
const res = await POST(postRequest({ workstreamId: "1" }), params());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ projectWorkstreamId: "9" });
});
it("500s without leaking the driver error", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mockSelectWorkstream.mockRejectedValue(new Error("connection reset"));
const res = await POST(postRequest({ workstreamId: "2" }), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "Couldn't add the workstream" });
consoleError.mockRestore();
});
});

View file

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

View file

@ -0,0 +1,80 @@
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 {
createProjectSchema,
prospectiveProjectFacts,
} from "@/lib/projects/createProject";
import { loadAuthzUserByEmail } from "@/app/repositories/projects/authzRepository";
import {
insertProject,
organisationExists,
projectTypeExists,
} from "@/app/repositories/projects/projectsRepository";
/**
* POST /api/projects create an Ara Project (issue #409).
*
* The authorization question "may this user create a project for this
* organisation?" is answered by the *existing* guard: build the facts the
* project would have once created and ask `canManageProject`. That keeps
* creation on the same rule as every other management action instead of
* inventing a parallel one.
*
* The rule has a consequence worth naming: `domna_admin_access` gates the
* `internal` role, so a Domna user creating a project for an organisation they
* are not a member of must grant that access. Without it they would be
* creating a project they could not then see, and the guard refuses rather
* than producing an orphan.
*/
export async function POST(req: NextRequest) {
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 parsed = createProjectSchema.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, prospectiveProjectFacts(draft))) {
return NextResponse.json(
{
error:
"You cannot create a project for that organisation. Granting Domna admin access is required for an organisation you are not a member of.",
},
{ status: 403 },
);
}
// Checked after authorization so an unauthorized caller cannot use the
// difference between 400 and 403 to probe which organisations exist.
const projectTypeId = BigInt(draft.projectTypeId);
const [orgOk, typeOk] = await Promise.all([
organisationExists(draft.organisationId),
projectTypeExists(projectTypeId),
]);
if (!orgOk) {
return NextResponse.json({ error: "Unknown organisation" }, { status: 400 });
}
if (!typeOk) {
return NextResponse.json({ error: "Unknown project type" }, { status: 400 });
}
const id = await insertProject({ ...draft, projectTypeId });
// `id` is a bigint; JSON cannot carry one, so the client gets a string and
// uses it to build the next wizard step's path.
return NextResponse.json({ id: id.toString() }, { status: 201 });
}

View file

@ -0,0 +1,166 @@
"use client";
import * as React from "react";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
import { Calendar } from "@/app/shadcn_components/ui/calendar";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import {
formatUkDate,
formatUkDateWhileTyping,
isoToLocalDate,
localDateToIso,
parseUkDate,
} from "@/utils/dates";
export interface DateInputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "type"
> {
/**
* The date as `YYYY-MM-DD`. Empty, `null` and `undefined` all mean "no date"
* an optional field's form value is `undefined` before it is touched.
*/
value: string | null | undefined;
/**
* Called with `YYYY-MM-DD` once a real date has been typed, and `""` while
* the field is still incomplete. A *complete* entry that is not a real date
* (`31/02/2026`) is passed through as typed, so the caller's schema rejects
* it see the note in the component body.
*/
onChange: (value: string) => void;
/** Cypress hook. The calendar trigger derives its own from this, suffixed. */
"data-testid"?: string;
}
/**
* A date field that reads and writes `dd/mm/yyyy`.
*
* Use this rather than `<input type="date">` anywhere a user enters a date.
* The native control's display format follows the *browser's* locale, not the
* document's so a US-configured browser shows `mm/dd/yyyy` on a UK product,
* with no attribute available to override it.
*
* A date can be typed or picked. The calendar in the popover replaces the one
* lost with the native control, and writes through the same value, so neither
* route is privileged: picking fills the text, typing moves the grid.
*
* The value crossing this boundary is always `YYYY-MM-DD`, so form state,
* request bodies and `date` columns are unaffected only what the user sees
* changes.
*/
export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
function DateInput({ value, onChange, ...props }, ref) {
// Normalised first: an untouched optional field is `undefined` and a
// cleared one is `""`. Treating them as different values would reset the
// text mid-keystroke, because typing reports `""` for incomplete input.
const current = value ?? "";
const [pickerOpen, setPickerOpen] = React.useState(false);
// The displayed text and the value it produced, held together so the pair
// can never disagree. Half-typed text ("03/0") has no ISO form, so the
// field cannot be driven by `value` alone.
const [state, setState] = React.useState(() => ({
text: formatUkDate(current),
value: current,
}));
// Adjusting state during render, not in an effect: when the form resets or
// sets this field from outside, the incoming value is authoritative and
// the displayed text is rebuilt from it. React re-runs the render with the
// new state before touching the DOM, so this costs no extra paint.
if (current !== state.value) {
setState({ text: formatUkDate(current), value: current });
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const raw = event.target.value;
const text = formatUkDateWhileTyping(raw, {
deleting: raw.length < state.text.length,
});
// Three outcomes, and the middle one is the reason this is not just
// `parseUkDate(text) ?? ""`. A field holding "31/02/2026" that reported
// itself empty would pass an optional-date schema silently, and the user
// would be told nothing about a date they can see in the box. So a
// finished-but-impossible date is handed over as typed, where the
// schema's `YYYY-MM-DD` check rejects it. Unfinished input is genuinely
// "no date yet" and reports empty, so no error appears mid-keystroke.
const finished = text.replace(/\D/g, "").length === 8;
const next = parseUkDate(text) ?? (finished ? text : "");
setState({ text, value: next });
onChange(next);
}
function handleSelect(date: Date | undefined) {
const next = date ? localDateToIso(date) : "";
setState({ text: formatUkDate(next), value: next });
onChange(next);
setPickerOpen(false);
}
// The date the grid should highlight and open on. A half-typed or
// impossible entry has none, in which case the picker opens on the current
// month rather than refusing to open.
const selected = isoToLocalDate(state.value) ?? undefined;
return (
<div className="relative">
<Input
{...props}
ref={ref}
// `text`, not `date` — see the component's docs.
type="text"
// Phones get the number pad; the mask supplies the separators.
inputMode="numeric"
autoComplete="off"
placeholder="dd/mm/yyyy"
maxLength={10}
value={state.text}
onChange={handleChange}
className={cn("pr-10", props.className)}
/>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<button
type="button"
// Typing stays the primary interaction, so the trigger sits
// inside the field rather than beside it: the input keeps the
// full width of its grid column, and the layout is unchanged
// from the native control it replaced.
className="absolute inset-y-0 right-0 flex w-10 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={props.disabled}
// The field itself is labelled; this button is not, and a bare
// icon has no accessible name without one.
aria-label="Choose a date from the calendar"
data-testid={
props["data-testid"]
? `${props["data-testid"]}-picker`
: undefined
}
>
<CalendarDaysIcon className="h-4 w-4" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
defaultMonth={selected}
autoFocus
/>
</PopoverContent>
</Popover>
</div>
);
}
);

View file

@ -0,0 +1,52 @@
CREATE TABLE "epc_roof_window" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"roof_window_index" integer NOT NULL,
"area_m2" double precision NOT NULL,
"u_value_raw" double precision NOT NULL,
"orientation" integer NOT NULL,
"pitch_deg" double precision NOT NULL,
"g_perpendicular" double precision NOT NULL,
"frame_factor" double precision NOT NULL,
"glazing_type" integer NOT NULL,
"window_location" jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "epc_room_in_roof" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_building_part_id" bigint NOT NULL,
"floor_area" double precision NOT NULL,
"construction_age_band" text NOT NULL,
"common_wall_length_m" double precision,
"common_wall_height_m" double precision,
"gable_1_length_m" double precision,
"gable_1_height_m" double precision,
"gable_2_length_m" double precision,
"gable_2_height_m" double precision,
CONSTRAINT "epc_room_in_roof_epc_building_part_id_unique" UNIQUE("epc_building_part_id")
);
--> statement-breakpoint
CREATE TABLE "epc_room_in_roof_surface" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_room_in_roof_id" bigint NOT NULL,
"surface_index" integer NOT NULL,
"kind" text NOT NULL,
"area_m2" double precision NOT NULL,
"insulation_thickness_mm" integer,
"insulation_type" text,
"u_value" double precision
);
--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD COLUMN "wall_u_value" double precision;--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD COLUMN "alt_wall_1_u_value" double precision;--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD COLUMN "alt_wall_1_thickness_mm" integer;--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD COLUMN "alt_wall_1_is_basement" boolean;--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD COLUMN "alt_wall_2_u_value" double precision;--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD COLUMN "alt_wall_2_thickness_mm" integer;--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD COLUMN "alt_wall_2_is_basement" boolean;--> statement-breakpoint
ALTER TABLE "epc_property" ADD COLUMN "heating_cylinder_heat_loss" double precision;--> statement-breakpoint
ALTER TABLE "epc_roof_window" ADD CONSTRAINT "epc_roof_window_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_room_in_roof" ADD CONSTRAINT "epc_room_in_roof_epc_building_part_id_epc_building_part_id_fk" FOREIGN KEY ("epc_building_part_id") REFERENCES "public"."epc_building_part"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_room_in_roof_surface" ADD CONSTRAINT "epc_room_in_roof_surface_epc_room_in_roof_id_epc_room_in_roof_id_fk" FOREIGN KEY ("epc_room_in_roof_id") REFERENCES "public"."epc_room_in_roof"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "uq_epc_roof_window_property_index" ON "epc_roof_window" USING btree ("epc_property_id","roof_window_index");--> statement-breakpoint
CREATE UNIQUE INDEX "uq_epc_room_in_roof_surface_index" ON "epc_room_in_roof_surface" USING btree ("epc_room_in_roof_id","surface_index");

View file

@ -0,0 +1,2 @@
ALTER TABLE "epc_building_part" DROP COLUMN "room_in_roof_floor_area";--> statement-breakpoint
ALTER TABLE "epc_building_part" DROP COLUMN "room_in_roof_construction_age_band";

View file

@ -0,0 +1,17 @@
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, as built' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 12 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 25 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 50 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 75 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 100 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 125 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 150 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 175 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 200 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 225 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 250 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 270 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 300 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 350 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 400 mm insulation' BEFORE 'Roof room(s), insulated';--> statement-breakpoint
ALTER TYPE "public"."roof_type" ADD VALUE 'Pitched, sloping ceiling, 400+ mm insulation' BEFORE 'Roof room(s), insulated';

View file

@ -0,0 +1,3 @@
ALTER TYPE "public"."main_heating_system" ADD VALUE 'Oil boiler, regular' BEFORE 'Electric storage heaters, old';--> statement-breakpoint
ALTER TYPE "public"."main_heating_system" ADD VALUE 'Oil boiler, combi' BEFORE 'Electric storage heaters, old';--> statement-breakpoint
ALTER TYPE "public"."main_heating_system" ADD VALUE 'Solid fuel boiler' BEFORE 'Electric storage heaters, old';

View file

@ -0,0 +1,23 @@
-- work_order.priority: text -> boolean (#419).
--
-- Hand-edited after `drizzle-kit generate`, which emitted only the SET DATA
-- TYPE and SET NOT NULL statements. Two corrections, both needed:
--
-- 1. Postgres refuses to cast text -> boolean implicitly, so the bare
-- `SET DATA TYPE boolean` fails with "column priority cannot be cast
-- automatically to type boolean". The USING clause below is that cast.
-- 2. drizzle-kit dropped the column's `DEFAULT false` from the SQL while
-- recording it in 0280_snapshot.json. Left as generated, the database
-- would end up NOT NULL with no default while drizzle believed a default
-- existed — so no later diff would ever add it, and an insert that omits
-- priority would fail.
--
-- work_order held 0 rows when this was written, so the cast evaluates nothing
-- and the backfill is a no-op. Both are kept anyway for the case where the
-- programme import (#417) lands rows before this migration is applied: a
-- priority value that is not castable to boolean should fail the migration
-- loudly rather than be coerced into a wrong answer.
ALTER TABLE "work_order" ALTER COLUMN "priority" SET DATA TYPE boolean USING "priority"::boolean;--> statement-breakpoint
UPDATE "work_order" SET "priority" = false WHERE "priority" IS NULL;--> statement-breakpoint
ALTER TABLE "work_order" ALTER COLUMN "priority" SET DEFAULT false;--> statement-breakpoint
ALTER TABLE "work_order" ALTER COLUMN "priority" SET NOT NULL;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1926,6 +1926,41 @@
"when": 1784656804281,
"tag": "0275_opposite_ronan",
"breakpoints": true
},
{
"idx": 276,
"version": "7",
"when": 1784798917178,
"tag": "0276_romantic_synch",
"breakpoints": true
},
{
"idx": 277,
"version": "7",
"when": 1784801404980,
"tag": "0277_woozy_prism",
"breakpoints": true
},
{
"idx": 278,
"version": "7",
"when": 1784810497155,
"tag": "0278_living_baron_strucker",
"breakpoints": true
},
{
"idx": 279,
"version": "7",
"when": 1784817538731,
"tag": "0279_blushing_mattie_franklin",
"breakpoints": true
},
{
"idx": 280,
"version": "7",
"when": 1784819275099,
"tag": "0280_flashy_dreadnoughts",
"breakpoints": true
}
]
}

View file

@ -130,6 +130,30 @@ export const RoofTypeValues: [string, ...string[]] = [
"Pitched, 350 mm loft insulation",
"Pitched, 400 mm loft insulation",
"Pitched, 400+ mm loft insulation",
// Pitched roof with a sloping ceiling — insulation sits at the rafter/slope
// line, NOT a horizontal loft, so it is modelled explicitly rather than
// flattened onto the "Pitched, N mm loft insulation" ladder. Depth ladder
// mirrors the loft one (12 mm … 400+ mm); "as built" defers the insulation
// assumption to the construction age band. Source EPC field is
// "PitchedWithSlopingCeiling"; this canonical name is intentionally our own.
// See Hestia-Homes/Model#1676 (roof overlay must model these at rafter level).
"Pitched, sloping ceiling, as built",
"Pitched, sloping ceiling, 12 mm insulation",
"Pitched, sloping ceiling, 25 mm insulation",
"Pitched, sloping ceiling, 50 mm insulation",
"Pitched, sloping ceiling, 75 mm insulation",
"Pitched, sloping ceiling, 100 mm insulation",
"Pitched, sloping ceiling, 125 mm insulation",
"Pitched, sloping ceiling, 150 mm insulation",
"Pitched, sloping ceiling, 175 mm insulation",
"Pitched, sloping ceiling, 200 mm insulation",
"Pitched, sloping ceiling, 225 mm insulation",
"Pitched, sloping ceiling, 250 mm insulation",
"Pitched, sloping ceiling, 270 mm insulation",
"Pitched, sloping ceiling, 300 mm insulation",
"Pitched, sloping ceiling, 350 mm insulation",
"Pitched, sloping ceiling, 400 mm insulation",
"Pitched, sloping ceiling, 400+ mm insulation",
"Roof room(s), insulated",
"Roof room(s), insulated (assumed)",
"Roof room(s), limited insulation",
@ -231,6 +255,14 @@ export const MainHeatingSystemValues: [string, ...string[]] = [
"Gas boiler, combi",
"Gas boiler, regular",
"Gas CPSU",
// Non-gas wet boilers. A fuel-agnostic "Boiler" description defaults to a gas
// archetype, which is wrong when the property's main_fuel is oil or a solid
// fuel — these give the classifier/overlay a correct target. Fuel granularity
// (house coal / wood logs / dual fuel) is carried by the separate `main_fuel`
// column, applied on top of the archetype (ADR-0041). See Hestia-Homes/Model#1676.
"Oil boiler, regular",
"Oil boiler, combi",
"Solid fuel boiler",
"Electric storage heaters, old",
"Electric storage heaters, slimline",
"Electric storage heaters, convector",

View file

@ -7,8 +7,11 @@
// `project_workstream_contractor.organisation_id` are uuid FKs to it.
// - Property has a bigint PK (see property.ts), so every `property_id` FK is
// bigint, not the integer the doc assumed.
// - status / priority columns whose enum values are still TBC are modelled as
// `text` for now; convert to pgEnum once the value sets are agreed.
// - status columns whose enum values are still TBC are modelled as `text` for
// now; convert to pgEnum once the value sets are agreed.
// - `work_order.priority` started as one of those `text` columns and is now a
// boolean: it turned out to be a flag ("look at this one first"), not a
// scale, so there is no value set left to agree. See CONTEXT.md, "Priority".
import {
type AnyPgColumn,
bigint,
@ -181,7 +184,10 @@ export const workOrder = pgTable("work_order", {
// Enum values TBC — modelled as text until the value sets are agreed.
status: text("status").notNull(),
forecastEnd: date("forecast_end"),
priority: text("priority"),
// A flag, not a scale: a work order either is flagged as priority or is not.
// NOT NULL DEFAULT false because a third "unknown" state has no meaning for a
// flag — the table renders it or renders nothing.
priority: boolean("priority").notNull().default(false),
});
export type Project = InferModel<typeof project, "select">;

View file

@ -66,9 +66,13 @@ Project
> optional evidence-requirement link) carries this.
> - **Property has a `bigint` PK** (`bigserial`), not an integer one. Every
> `property_id` FK is `bigint`.
> - **`status` / `priority` columns are `text` for now**, not Postgres enums —
> their value sets are still TBC and an empty enum can't be created. Convert to
> `pgEnum` once the values are agreed.
> - **`status` columns are `text` for now**, not Postgres enums — their value
> sets are still TBC and an empty enum can't be created. Convert to `pgEnum`
> once the values are agreed.
> - **`work_order.priority` is a `boolean`**, not one of those `text` columns.
> It is a flag ("look at this one first"), not a scale, so no value set was
> ever needed. Changed under #419, when the admin work-orders table surfaced
> it as a column.
> - **No new `uploaded_by` column** on `uploaded_files`; the existing
> `uploaded_by` `bigint` FK → user is reused.
> - **No `document_type` table.** Evidence requirements reference the existing
@ -256,7 +260,7 @@ Represents work issued to a contractor.
| project_workstream_stage_id | bigint | No | FK → Project Workstream Stage |
| status | text | No | Values TBC — `text` for now, convert to Postgres enum once agreed |
| forecast_end | date | Yes | |
| priority | text | Yes | Values TBC — `text` for now, convert to Postgres enum once agreed |
| priority | boolean | No | Flag, not a scale — "look at this one first". Default `false` |
---

View file

@ -583,6 +583,7 @@ export const epcProperty = pgTable(
heatingSecondaryHeatingType: jsonb("heating_secondary_heating_type"),
heatingCylinderInsulationThicknessMm: integer("heating_cylinder_insulation_thickness_mm"),
heatingCylinderVolumeMeasuredL: integer("heating_cylinder_volume_measured_l"), // litres, nullable
heatingCylinderHeatLoss: doublePrecision("heating_cylinder_heat_loss"), // nullable
heatingWwhrsIndexNumber1: integer("heating_wwhrs_index_number_1"),
heatingWwhrsIndexNumber2: integer("heating_wwhrs_index_number_2"),
heatingShowerOutletType: jsonb("heating_shower_outlet_type"),
@ -799,10 +800,17 @@ export const epcBuildingPart = pgTable(
// the result: a system-built wall bills as a basement wall, and via has_basement
// drags the whole ground floor onto the Table 23 basement-floor U-value.
wallIsBasement: boolean("wall_is_basement"),
// Lodged gov-EPC wall U-value. Unlike the columns dropped in #1661, the
// calculator now honours this over the derived construction-default (#1665).
// Nullable: null means "not lodged → the calculator derives it". double
// precision to match the other new #1665 floats and keep the round-trip exact.
wallUValue: doublePrecision("wall_u_value"),
// Room in roof (inlined)
roomInRoofFloorArea: real("room_in_roof_floor_area"),
roomInRoofConstructionAgeBand: text("room_in_roof_construction_age_band"),
// Room-in-roof geometry now lives in the epc_room_in_roof /
// epc_room_in_roof_surface child tables (Model #1665). The two flattened
// columns that used to sit here (room_in_roof_floor_area,
// room_in_roof_construction_age_band) were dropped as their redundant
// duplicates.
// Alternative wall 1 (inlined)
altWall1Area: real("alt_wall_1_area"),
@ -812,6 +820,12 @@ export const epcBuildingPart = pgTable(
altWall1ThicknessMeasured: text("alt_wall_1_thickness_measured"),
altWall1InsulationThickness: text("alt_wall_1_insulation_thickness"),
altWall1IsSheltered: boolean("alt_wall_1_is_sheltered"), // nullable (null when no alt wall)
altWall1UValue: doublePrecision("alt_wall_1_u_value"), // nullable
altWall1ThicknessMm: integer("alt_wall_1_thickness_mm"), // nullable
// NULLABLE, load-bearing — do NOT make it notNull().default(false). null "not
// stated" vs false "explicitly not a basement" are distinct; collapsing them
// re-enables the code-6 basement heuristic this flag exists to defeat.
altWall1IsBasement: boolean("alt_wall_1_is_basement"),
// Alternative wall 2 (inlined)
altWall2Area: real("alt_wall_2_area"),
@ -821,6 +835,10 @@ export const epcBuildingPart = pgTable(
altWall2ThicknessMeasured: text("alt_wall_2_thickness_measured"),
altWall2InsulationThickness: text("alt_wall_2_insulation_thickness"),
altWall2IsSheltered: boolean("alt_wall_2_is_sheltered"), // nullable (null when no alt wall)
altWall2UValue: doublePrecision("alt_wall_2_u_value"), // nullable
altWall2ThicknessMm: integer("alt_wall_2_thickness_mm"), // nullable
// NULLABLE, load-bearing — see altWall1IsBasement above.
altWall2IsBasement: boolean("alt_wall_2_is_basement"),
},
(table) => [
// Per-EPC part lookups: the reporting age-band subquery and the
@ -856,6 +874,84 @@ export const epcFloorDimension = pgTable(
],
);
// ─── epc_roof_window ──────────────────────────────────────────────────────────
// 0..n per epc_property. No explicit onDelete: matches every existing EPC child
// FK (epc_photovoltaic_array, epc_window, …) — the Python backend owns these
// tables and rewrites children by delete-and-reinsert on re-ingest.
export const epcRoofWindow = pgTable(
"epc_roof_window",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcPropertyId: bigint("epc_property_id", { mode: "bigint" })
.notNull()
.references(() => epcProperty.id),
roofWindowIndex: integer("roof_window_index").notNull(), // preserves list order for round-trip
areaM2: doublePrecision("area_m2").notNull(),
uValueRaw: doublePrecision("u_value_raw").notNull(), // Table 24 roof-window U, pre-curtain
orientation: integer("orientation").notNull(), // SAP orientation code
pitchDeg: doublePrecision("pitch_deg").notNull(),
gPerpendicular: doublePrecision("g_perpendicular").notNull(),
frameFactor: doublePrecision("frame_factor").notNull(),
glazingType: integer("glazing_type").notNull(), // SAP Table U2 code
windowLocation: jsonb("window_location").notNull(), // int (API) or str (site-notes) — jsonb preserves type
},
(t) => [
uniqueIndex("uq_epc_roof_window_property_index").on(t.epcPropertyId, t.roofWindowIndex),
],
);
// ─── epc_room_in_roof ─────────────────────────────────────────────────────────
// 0..1 per epc_building_part. Supersedes the inlined room_in_roof_floor_area /
// room_in_roof_construction_age_band columns on epc_building_part, which are left
// in place here and dropped in a follow-up migration (see Model #1665).
export const epcRoomInRoof = pgTable(
"epc_room_in_roof",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcBuildingPartId: bigint("epc_building_part_id", { mode: "bigint" })
.notNull()
.unique() // 0..1 per part
.references(() => epcBuildingPart.id),
floorArea: doublePrecision("floor_area").notNull(),
constructionAgeBand: text("construction_age_band").notNull(),
commonWallLengthM: doublePrecision("common_wall_length_m"), // nullable
commonWallHeightM: doublePrecision("common_wall_height_m"), // nullable
gable1LengthM: doublePrecision("gable_1_length_m"), // nullable
gable1HeightM: doublePrecision("gable_1_height_m"), // nullable
gable2LengthM: doublePrecision("gable_2_length_m"), // nullable
gable2HeightM: doublePrecision("gable_2_height_m"), // nullable
},
);
// ─── epc_room_in_roof_surface ─────────────────────────────────────────────────
export const epcRoomInRoofSurface = pgTable(
"epc_room_in_roof_surface",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcRoomInRoofId: bigint("epc_room_in_roof_id", { mode: "bigint" })
.notNull()
.references(() => epcRoomInRoof.id),
surfaceIndex: integer("surface_index").notNull(), // preserves list order
// slope|flat_ceiling|stud_wall|gable_wall|gable_wall_external|gable_wall_sheltered
// |common_wall|connected_wall — TEXT, not a pg enum (the set has grown twice).
kind: text("kind").notNull(),
areaM2: doublePrecision("area_m2").notNull(), // CAN BE NEGATIVE (signed §3.9.2 adjustment) — no CHECK >= 0
// NULLABLE — null vs 0 is the U-value branch (Table 17 vs default).
insulationThicknessMm: integer("insulation_thickness_mm"),
insulationType: text("insulation_type"), // nullable: mineral_wool|eps|pur|pir
uValue: doublePrecision("u_value"), // NULLABLE — assessor U override; null ≠ 0
},
(t) => [
uniqueIndex("uq_epc_room_in_roof_surface_index").on(t.epcRoomInRoofId, t.surfaceIndex),
],
);
// ─── epc_window ───────────────────────────────────────────────────────────────
export const epcWindow = pgTable(

View file

@ -0,0 +1,244 @@
/**
* Programme-dashboard load fixture (issue #418).
*
* Seeds one Ara Project with ~1,500 work orders spread across workstreams,
* stages, contractors and forecast dates, so a human can check that
* `/projects/[projectId]` renders acceptably at programme scale the issue's
* acceptance criterion.
*
* ## THIS SCRIPT WAS NEVER RUN
*
* It is deliberately shipped unexecuted. The `DATABASE_URL` available while
* this issue was implemented points at the **production** database one
* database shared by the deployed site and every dev environment so seeding
* 1,500 synthetic work orders from here would have written fabricated delivery
* records into live data. Run it yourself, against a non-production database.
*
* ## Running it
*
* ALLOW_PROJECTS_DASHBOARD_SEED=i-am-not-production \
* npx tsx src/app/db/seed/seed-projects-dashboard.ts <projectId>
*
* The guard below refuses to do anything without that variable, and refuses
* outright when `NODE_ENV=production`. It is a speed bump, not a safety net:
* the only real protection is pointing `DB_HOST` somewhere disposable. Check
* `DB_HOST` before you run it the script prints it and pauses.
*
* Everything it writes is prefixed `SEED-418-` (work-order references) or named
* `[seed-418] …`, so it can be found and removed again:
*
* DELETE FROM work_order WHERE reference LIKE 'SEED-418-%';
*
* The project itself must already exist, along with at least one Property
* linked to it via `project_property` and one `organisation` to act as
* contractor; the script reuses those rather than inventing properties.
*/
import { eq } from "drizzle-orm";
import { db, pool } from "@/app/db/db";
import {
project,
projectProperty,
projectWorkstream,
projectWorkstreamContractor,
projectWorkstreamStage,
workOrder,
workstream,
} from "@/app/db/schema/projects/projects";
import { organisation } from "@/app/db/schema/organisation";
/** Total work orders to create — the scale the acceptance criterion names. */
const TARGET_WORK_ORDERS = 1500;
/** The v1 default ladder (CONTEXT.md, "Stage"). Terminal = highest `order`. */
const STAGE_LADDER = [
"Ordered",
"In progress",
"Completed",
"Charged",
"Closed",
] as const;
/** Workstreams to instantiate, by `workstream.name` in the reference data. */
const WORKSTREAM_NAMES = [
"Windows",
"Doors",
"Roofs",
"Electrical",
"Heating",
] as const;
/** How many contractor organisations to spread the work across. */
const CONTRACTOR_COUNT = 3;
async function main() {
assertSeedingIsAllowed();
const projectId = readProjectIdArg();
const [found] = await db
.select({ id: project.id, name: project.name, organisationId: project.organisationId })
.from(project)
.where(eq(project.id, projectId))
.limit(1);
if (!found) throw new Error(`No project with id ${projectId}`);
console.log(`Seeding project ${found.id} ("${found.name}")`);
console.log(` DB_HOST = ${process.env.DB_HOST ?? "(unset)"}`);
console.log(` DB_NAME = ${process.env.DB_NAME ?? "(unset)"}`);
await pause();
const propertyIds = (
await db
.select({ propertyId: projectProperty.propertyId })
.from(projectProperty)
.where(eq(projectProperty.projectId, projectId))
).map((r) => r.propertyId);
if (propertyIds.length === 0) {
throw new Error(
`Project ${projectId} has no properties in project_property — link some first.`,
);
}
const contractorOrgIds = (
await db.select({ id: organisation.id }).from(organisation).limit(CONTRACTOR_COUNT)
).map((r) => r.id);
if (contractorOrgIds.length === 0) throw new Error("No organisations to use as contractors.");
const workstreamIds = await resolveWorkstreamReferenceData();
// One Project Workstream per reference workstream, each with its own full
// ladder and its own contractor assignments.
const assignments: { stageIds: bigint[]; contractorIds: bigint[] }[] = [];
for (const workstreamId of workstreamIds) {
const [createdWorkstream] = await db
.insert(projectWorkstream)
.values({ projectId, workstreamId })
.returning({ id: projectWorkstream.id });
const stageIds: bigint[] = [];
for (const [index, name] of STAGE_LADDER.entries()) {
const [stage] = await db
.insert(projectWorkstreamStage)
.values({
projectWorkstreamId: createdWorkstream.id,
name,
order: index + 1,
status: "active",
})
.returning({ id: projectWorkstreamStage.id });
stageIds.push(stage.id);
}
const contractorIds: bigint[] = [];
for (const organisationId of contractorOrgIds) {
const [contractor] = await db
.insert(projectWorkstreamContractor)
.values({
projectWorkstreamId: createdWorkstream.id,
organisationId,
updateStagesPermission: true,
uploadDocumentsPermission: true,
})
.returning({ id: projectWorkstreamContractor.id });
contractorIds.push(contractor.id);
}
assignments.push({ stageIds, contractorIds });
}
// Deterministic spread — no randomness, so two runs of the fixture produce
// the same dashboard and a rendering regression is visible as a diff.
const rows: (typeof workOrder.$inferInsert)[] = [];
for (let i = 0; i < TARGET_WORK_ORDERS; i++) {
const assignment = assignments[i % assignments.length];
const stageId = assignment.stageIds[i % assignment.stageIds.length];
const contractorId = assignment.contractorIds[i % assignment.contractorIds.length];
rows.push({
reference: `SEED-418-${String(i).padStart(5, "0")}`,
projectWorkstreamContractorId: contractorId,
propertyId: propertyIds[i % propertyIds.length],
projectWorkstreamStageId: stageId,
status: "seeded",
// Every third order sits in the past, so the overdue rule has something
// to find on non-terminal stages — and something to *ignore* on terminal
// ones, which is the case worth eyeballing.
forecastEnd: offsetDay(i % 3 === 0 ? -30 : 30),
});
}
// Chunked so the insert does not exceed the parameter limit.
const CHUNK = 500;
for (let i = 0; i < rows.length; i += CHUNK) {
await db.insert(workOrder).values(rows.slice(i, i + CHUNK));
console.log(` inserted ${Math.min(i + CHUNK, rows.length)}/${rows.length}`);
}
console.log(`Done. Open /projects/${projectId} and check the render.`);
}
/**
* Ensure the reference `workstream` rows this fixture needs exist, creating any
* that do not. Reference data proper is #407's job; this only fills gaps so the
* fixture is runnable on a bare database.
*/
async function resolveWorkstreamReferenceData(): Promise<bigint[]> {
const ids: bigint[] = [];
for (const name of WORKSTREAM_NAMES) {
const [existing] = await db
.select({ id: workstream.id })
.from(workstream)
.where(eq(workstream.name, name))
.limit(1);
if (existing) {
ids.push(existing.id);
continue;
}
const [created] = await db
.insert(workstream)
.values({ name, description: `[seed-418] ${name}` })
.returning({ id: workstream.id });
ids.push(created.id);
}
return ids;
}
/** Refuse to run without an explicit opt-in, and never under NODE_ENV=production. */
function assertSeedingIsAllowed(): void {
if (process.env.NODE_ENV === "production") {
throw new Error("Refusing to seed: NODE_ENV=production.");
}
if (process.env.ALLOW_PROJECTS_DASHBOARD_SEED !== "i-am-not-production") {
throw new Error(
"Refusing to seed. This writes ~1,500 fabricated work orders.\n" +
"Point DB_HOST at a non-production database, then re-run with\n" +
" ALLOW_PROJECTS_DASHBOARD_SEED=i-am-not-production",
);
}
}
function readProjectIdArg(): bigint {
const raw = process.argv[2];
if (!raw || !/^\d+$/.test(raw)) {
throw new Error("Usage: tsx src/app/db/seed/seed-projects-dashboard.ts <projectId>");
}
return BigInt(raw);
}
/** A `YYYY-MM-DD` day `days` from today, for `work_order.forecast_end`. */
function offsetDay(days: number): string {
const date = new Date();
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
/** Five seconds to read the printed host and hit Ctrl-C. */
function pause(): Promise<void> {
console.log(" starting in 5s — Ctrl-C now if that is not a scratch database");
return new Promise((resolve) => setTimeout(resolve, 5000));
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
})
.finally(() => pool.end());

View file

@ -13,7 +13,11 @@ import { user as userTable } from "@/app/db/schema/users";
import type { DocStatus, PortfolioCapabilityType } from "../types";
import { deriveEffectiveRemovalState } from "../removalState";
import { classifyDeals } from "../transforms";
import { fetchDocsByDealId, computeDocStatusMap } from "../docStatus";
import {
fetchDocsByDealId,
computeDocStatusMap,
deriveDesignDocDealIds,
} from "../docStatus";
import { coordinatorUser, designerUser, mapDbRowToHubspotDeal } from "../dealQuery";
import type { DealRow } from "../dealQuery";
import DealPage from "./DealPage";
@ -74,7 +78,13 @@ export default async function DealDetailPage(props: {
}
const hubspotDeal = mapDbRowToHubspotDeal(rawDeals[0]);
const [deal] = classifyDeals([hubspotDeal]);
// Fetch this deal's documents before classifying so Retrofit Design Document
// presence drives the Design-vs-Installation boundary, consistent with the
// Live projects list. The same documents feed the document-status map below.
const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]);
const designDocDealIds = deriveDesignDocDealIds(docsByDealId);
const [deal] = classifyDeals([hubspotDeal], designDocDealIds);
const userEmail = session.user.email;
let userCapability: PortfolioCapabilityType = [];
@ -151,7 +161,6 @@ export default async function DealDetailPage(props: {
? deriveEffectiveRemovalState(removalRows[0])
: "none";
const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]);
const docStatusMap = computeDocStatusMap([hubspotDeal], docsByDealId, { [dealId]: approvedMeasures });
const docStatus: DocStatus = docStatusMap[dealId] ?? {
presentSurveyTypes: [],

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { computeDocStatusMap } from "./docStatus";
import { computeDocStatusMap, deriveDesignDocDealIds } from "./docStatus";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES } from "./types";
import type { HubspotDeal, ApprovalsByDeal } from "./types";
@ -81,6 +81,40 @@ const allSurveyDocs = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.map((t) => ({
measureName: null,
}));
describe("deriveDesignDocDealIds", () => {
it("includes a deal that holds a Retrofit Design Document", () => {
// Arrange
const docsByDealId = new Map([
["deal-1", [{ fileType: "retrofit_design_doc", measureName: null }]],
]);
// Act
const designDocDealIds = deriveDesignDocDealIds(docsByDealId);
// Assert
expect(designDocDealIds.has("deal-1")).toBe(true);
});
it("excludes a deal whose documents contain no Retrofit Design Document", () => {
// Arrange — only survey/install docs, no design document
const docsByDealId = new Map([
[
"deal-1",
[
{ fileType: "photo_pack", measureName: null },
{ fileType: "pre_photo", measureName: "CWI" },
],
],
]);
// Act
const designDocDealIds = deriveDesignDocDealIds(docsByDealId);
// Assert
expect(designDocDealIds.has("deal-1")).toBe(false);
});
});
describe("computeDocStatusMap", () => {
it("marks survey as complete when all 9 mandatory doc types are present", () => {
const deal = makeDeal({ dealId: "deal-1" });
@ -123,6 +157,19 @@ describe("computeDocStatusMap", () => {
expect(measureNames).not.toContain("CWI");
});
it("does not report install documents for a deal holding only a Retrofit Design Document", () => {
// Arrange — a design document is not an install document
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: "CWI" });
const docs = new Map([["deal-1", [{ fileType: "retrofit_design_doc", measureName: null }]]]);
// Act
const result = computeDocStatusMap([deal], docs, {});
// Assert
expect(result["deal-1"].hasInstallDocs).toBe(false);
expect(result["deal-1"].installStatus).toBe("none");
});
describe("installStatus", () => {
// CWI requires only BASE_DOCS — simple to satisfy in tests
const cwi = "CWI";
@ -169,6 +216,69 @@ describe("computeDocStatusMap", () => {
expect(result["deal-1"].installStatus).toBe("hasDocs");
});
it('is "none" when the only docs are coordination docs, which the drawer lists separately', () => {
// Arrange
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: null });
const coordinationDocs = [
{ fileType: "improvement_option_evaluation", measureName: null },
];
const docs = new Map([["deal-1", coordinationDocs]]);
// Act
const result = computeDocStatusMap([deal], docs, {});
// Assert
expect(result["deal-1"].installStatus).toBe("none");
});
it('is "none" when the only docs are design docs, which the drawer lists separately', () => {
// Arrange
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: null });
const designDocs = [{ fileType: "retrofit_design_doc", measureName: null }];
const docs = new Map([["deal-1", designDocs]]);
// Act
const result = computeDocStatusMap([deal], docs, {});
// Assert
expect(result["deal-1"].installStatus).toBe("none");
});
it("counts only genuine install docs when coordination and design docs are also present", () => {
// Arrange
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: cwi });
const mixedDocs = [
{ fileType: "improvement_option_evaluation", measureName: null },
{ fileType: "retrofit_design_doc", measureName: null },
...cwiRequiredDocs.map((ft) => ({ fileType: ft, measureName: cwi })),
];
const docs = new Map([["deal-1", mixedDocs]]);
// Act
const result = computeDocStatusMap([deal], docs, {});
// Assert
expect(result["deal-1"].measureProgress).toEqual([
expect.objectContaining({ measureName: cwi, uploadedCount: cwiRequiredDocs.length }),
]);
});
it("reports no install docs when only coordination and design docs are present", () => {
// Arrange
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: null });
const nonInstallDocs = [
{ fileType: "medium_term_improvement_plan", measureName: null },
{ fileType: "retrofit_design_doc", measureName: null },
];
const docs = new Map([["deal-1", nonInstallDocs]]);
// Act
const result = computeDocStatusMap([deal], docs, {});
// Assert
expect(result["deal-1"].hasInstallDocs).toBe(false);
});
it('is "none" when measures are defined but no install docs have been uploaded', () => {
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: cwi });
// Only survey docs — no install docs

View file

@ -2,9 +2,11 @@ import { inArray } from "drizzle-orm";
import { db } from "@/app/db/db";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import { isInstallDocType } from "./propertyDocuments";
import {
EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
SURVEY_ALL_DOC_TYPES,
DESIGN_DOC_TYPES,
} from "./types";
import type {
HubspotDeal,
@ -81,6 +83,24 @@ export async function fetchDocsByDealId(
return docsByDealId;
}
/**
* The single source of truth for "which deals have a Retrofit Design Document".
* Derived from the already-fetched per-deal documents (which include both the
* direct deal-id match and the UPRN fallback), so no extra query is needed.
* `DESIGN_DOC_TYPES` defines what counts as a design document.
*/
export function deriveDesignDocDealIds(
docsByDealId: Map<string, DocEntry[]>,
): Set<string> {
const designDocDealIds = new Set<string>();
for (const [dealId, docs] of docsByDealId) {
if (docs.some((d) => DESIGN_DOC_TYPES.has(d.fileType))) {
designDocDealIds.add(dealId);
}
}
return designDocDealIds;
}
export function computeDocStatusMap(
deals: HubspotDeal[],
docsByDealId: Map<string, Array<{ fileType: string; measureName: string | null }>>,
@ -103,7 +123,12 @@ export function computeDocStatusMap(
for (const [dealId, docs] of docsByDealId) {
const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
// Survey, coordination and design docs each get their own drawer section,
// so none count as install docs. `isInstallDocType` is the shared predicate
// that keeps this in sync with the drawer's own split — re-implementing it
// inline once dropped the coordination carve-out and mis-bucketed
// coordination-only docs as install documents.
const installDocs = docs.filter((d) => isInstallDocType(d.fileType));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measures = measuresByDealId.get(dealId) ?? [];

View file

@ -4,7 +4,11 @@ import { redirect } from "next/navigation";
import { eq, inArray, and, desc, sql } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { fetchDocsByDealId, computeDocStatusMap } from "./docStatus";
import {
fetchDocsByDealId,
computeDocStatusMap,
deriveDesignDocDealIds,
} from "./docStatus";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
@ -112,7 +116,14 @@ export default async function LiveReportingPage(props: {
.where(inArray(hubspotDealData.companyId, companyIds));
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
const dealIds = deals.map((d) => d.dealId).filter(Boolean);
// Fetch per-deal documents before classification so Retrofit Design Document
// presence can drive the Design-vs-Installation boundary. The same fetched
// documents feed the document-status map below.
const docsByDealId = await fetchDocsByDealId(deals, dealIds);
const designDocDealIds = deriveDesignDocDealIds(docsByDealId);
const trackerData = computeLiveTrackerData(deals, designDocDealIds);
const userEmail = user?.user?.email;
@ -165,7 +176,6 @@ export default async function LiveReportingPage(props: {
// Fetch currently approved measures for all deals in scope
const approvalsByDeal: ApprovalsByDeal = {};
const dealIds = deals.map((d) => d.dealId).filter(Boolean);
if (dealIds.length > 0) {
const approvalRows = await db
.select({
@ -218,7 +228,6 @@ export default async function LiveReportingPage(props: {
const removalStatusByDeal: RemovalStatusByDeal = computeRemovalStatusByDeal(removalRows);
const docsByDealId = await fetchDocsByDealId(deals, dealIds);
const docStatusMap = computeDocStatusMap(deals, docsByDealId, approvalsByDeal);
return (

View file

@ -6,6 +6,19 @@ import {
} from "./types";
import type { PropertyDocument, MeasureDocProgress } from "./types";
// Survey, coordination and design docs each get their own section in the
// property drawer, so none of them count as install docs. Kept as a single
// predicate shared with computeDocStatusMap: when the two drifted apart, the
// Install Docs badge in the properties table read "Has Docs" for properties
// whose drawer showed no install documents at all.
export function isInstallDocType(docType: string): boolean {
return (
!SURVEY_ALL_DOC_TYPES.has(docType) &&
!COORDINATION_DOC_TYPES.has(docType) &&
!DESIGN_DOC_TYPES.has(docType)
);
}
export function splitDocumentsByType(docs: PropertyDocument[]): {
docs: PropertyDocument[];
coordinationDocs: PropertyDocument[];
@ -16,12 +29,7 @@ export function splitDocumentsByType(docs: PropertyDocument[]): {
docs: docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.docType)),
coordinationDocs: docs.filter((d) => COORDINATION_DOC_TYPES.has(d.docType)),
designDocs: docs.filter((d) => DESIGN_DOC_TYPES.has(d.docType)),
installDocs: docs.filter(
(d) =>
!SURVEY_ALL_DOC_TYPES.has(d.docType) &&
!COORDINATION_DOC_TYPES.has(d.docType) &&
!DESIGN_DOC_TYPES.has(d.docType),
),
installDocs: docs.filter((d) => isInstallDocType(d.docType)),
};
}

View file

@ -300,81 +300,123 @@ describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => {
).toBe("Design in Progress");
});
it("is case-insensitive for coord/design status checks", () => {
it("is case-insensitive for the coordination-complete pattern", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(v1) ioe/mtp complete",
designStatus: "uploaded",
}))
).toBe("Installation in Progress"); // POST_DESIGN, no install fields set
resolveDisplayStage(
makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(v1) ioe/mtp complete",
}),
true // Retrofit Design Document present
)
).toBe("Installation in Progress");
});
});
describe("resolveDisplayStage — POST_DESIGN sub-classification (design UPLOADED)", () => {
describe("resolveDisplayStage — after-coordination precedence (downstream evidence wins over document presence)", () => {
const AFTER_ASSESSMENT_STAGE = "3948185842";
function makePostDesignDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
// A coordination-complete deal. designStatus is deliberately unset — it no
// longer influences classification; the Retrofit Design Document (passed via
// hasDesignDoc) is the source of truth for the Design/Installation boundary.
function makeCoordinationCompleteDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
return makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V1) IOE/MTP COMPLETE",
designStatus: "UPLOADED",
...overrides,
});
}
it("returns Installation in Progress when no install fields are set", () => {
expect(resolveDisplayStage(makePostDesignDeal())).toBe("Installation in Progress");
it("returns Design in Progress when the design document is absent and there is no downstream evidence", () => {
expect(resolveDisplayStage(makeCoordinationCompleteDeal(), false)).toBe("Design in Progress");
});
it("returns Installation Complete when actualMeasuresInstalled is set", () => {
it("returns Installation Complete when actualMeasuresInstalled is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ actualMeasuresInstalled: "Insulation" }))
resolveDisplayStage(makeCoordinationCompleteDeal({ actualMeasuresInstalled: "Insulation" }), false)
).toBe("Installation Complete");
});
it("returns Installation Complete when installerHandover is set", () => {
it("returns Installation Complete when installerHandover is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ installerHandover: "2024-01-01" }))
resolveDisplayStage(makeCoordinationCompleteDeal({ installerHandover: "2024-01-01" }), false)
).toBe("Installation Complete");
});
it("returns At Lodgement when lodgementStatus is set", () => {
it("returns At Lodgement when lodgementStatus is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ lodgementStatus: "Submitted" }))
resolveDisplayStage(makeCoordinationCompleteDeal({ lodgementStatus: "Submitted" }), false)
).toBe("At Lodgement");
});
it("returns At Post Survey when measuresLodgementDate is set", () => {
it("returns At Post Survey when measuresLodgementDate is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ measuresLodgementDate: new Date() }))
resolveDisplayStage(makeCoordinationCompleteDeal({ measuresLodgementDate: new Date() }), false)
).toBe("At Post Survey");
});
it("returns Project Complete when fullLodgementDate is set", () => {
it("returns Project Complete when fullLodgementDate is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ fullLodgementDate: new Date() }))
resolveDisplayStage(makeCoordinationCompleteDeal({ fullLodgementDate: new Date() }), false)
).toBe("Project Complete");
});
it("fullLodgementDate takes precedence over measuresLodgementDate and lodgementStatus", () => {
expect(
resolveDisplayStage(makePostDesignDeal({
fullLodgementDate: new Date(),
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}))
resolveDisplayStage(
makeCoordinationCompleteDeal({
fullLodgementDate: new Date(),
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}),
false
)
).toBe("Project Complete");
});
it("measuresLodgementDate takes precedence over lodgementStatus", () => {
expect(
resolveDisplayStage(makePostDesignDeal({
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}))
resolveDisplayStage(
makeCoordinationCompleteDeal({
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}),
false
)
).toBe("At Post Survey");
});
it("does NOT return Installation in Progress for designStatus UPLOADED when the design document is absent (regression for the reported bug)", () => {
// The HubSpot designStatus flag can be set before — or without — the
// Retrofit Design Document ever being uploaded. It must no longer push a
// Property into Installation in Progress.
expect(
resolveDisplayStage(makeCoordinationCompleteDeal({ designStatus: "UPLOADED" }), false)
).toBe("Design in Progress");
});
});
describe("resolveDisplayStage — design-document presence drives the Design/Installation boundary", () => {
const AFTER_ASSESSMENT_STAGE = "3948185842";
function makeCoordinationCompleteDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
return makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V1) IOE/MTP COMPLETE",
...overrides,
});
}
it("returns Installation in Progress when the Retrofit Design Document is present and there is no downstream evidence", () => {
// Arrange
const deal = makeCoordinationCompleteDeal();
// Act
const stage = resolveDisplayStage(deal, true);
// Assert
expect(stage).toBe("Installation in Progress");
});
});
// -----------------------------------------------------------------------
@ -402,6 +444,19 @@ describe("classifyDeals", () => {
expect(result.dealId).toBe("abc-123");
expect(result.dealname).toBe("Test");
});
it("classifies a coordination-complete deal as Installation in Progress only when its id is in the design-document set", () => {
// Arrange
const withDoc = makeDeal({ dealId: "has-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" });
const withoutDoc = makeDeal({ dealId: "no-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" });
// Act
const [a, b] = classifyDeals([withDoc, withoutDoc], new Set(["has-doc"]));
// Assert
expect(a.displayStage).toBe("Installation in Progress");
expect(b.displayStage).toBe("Design in Progress");
});
});
// -----------------------------------------------------------------------
@ -668,6 +723,25 @@ describe("computeLiveTrackerData", () => {
expect(result.projects).toHaveLength(0);
expect(result.totalDeals).toBe(0);
});
it("reflects design-document presence in the project roll-up stage counts", () => {
// Arrange — two coordination-complete deals in one project, one with a
// Retrofit Design Document and one without.
const deals = [
makeDeal({ projectCode: "PROJ-A", dealId: "has-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" }),
makeDeal({ projectCode: "PROJ-A", dealId: "no-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" }),
];
// Act
const result = computeLiveTrackerData(deals, new Set(["has-doc"]));
// Assert
const stageProgress = result.projects[0].progress.stageProgress;
const design = stageProgress.find((s) => s.stage === "Design in Progress")!;
const install = stageProgress.find((s) => s.stage === "Installation in Progress")!;
expect(design.count).toBe(1);
expect(install.count).toBe(1);
});
});
describe("hasMajorConditionIssue", () => {

View file

@ -70,14 +70,16 @@ const STAGE_ID_MAP: Record<string, string> = {
// -----------------------------------------------------------------------
// After-assessment sub-classification
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus + designStatus
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus alone.
// Whether coordination is complete is a CRM signal; what happens *after*
// coordination (design vs installation and beyond) is resolved separately
// by resolveAfterCoordinationStage, using document presence + downstream
// evidence rather than the self-reported designStatus flag.
// -----------------------------------------------------------------------
function resolveAfterAssessmentStage(
coordinationStatus: string | null,
designStatus: string | null
): "Coordination in Progress" | "Design in Progress" | "POST_DESIGN" | "Queries" {
coordinationStatus: string | null
): "Coordination in Progress" | "AFTER_COORDINATION" | "Queries" {
const coord = coordinationStatus?.toUpperCase() ?? "";
const design = designStatus?.toUpperCase() ?? "";
// RA ISSUE always -> Queries
if (coord === "RA ISSUE") return "Queries";
@ -88,7 +90,7 @@ function resolveAfterAssessmentStage(
coord.includes("(V2) IOE/MTP COMPLETE") ||
coord.includes("(V3) IOE/MTP COMPLETE")
) {
return design === "UPLOADED" ? "POST_DESIGN" : "Design in Progress";
return "AFTER_COORDINATION";
}
// Default for AFTER_ASSESSMENT
@ -96,22 +98,34 @@ function resolveAfterAssessmentStage(
}
// -----------------------------------------------------------------------
// Post-design sub-classification
// Called when design is UPLOADED — resolves install / lodgement / completed
// After-coordination sub-classification
// Resolves a coordination-complete deal to design / installation / lodgement
// / completed. Downstream evidence (measures installed, installer handover,
// lodgement, completion) always wins; document presence only decides the
// Design-vs-Installation boundary. Gating the whole block on document
// presence would drag a genuinely installed/lodged Property back to Design
// in Progress when its design document is missing from the store — see
// ADR-0023.
// -----------------------------------------------------------------------
function resolvePostDesignStage(deal: HubspotDeal): DisplayStage {
function resolveAfterCoordinationStage(
deal: HubspotDeal,
hasDesignDoc: boolean
): DisplayStage {
if (deal.fullLodgementDate) return "Project Complete";
if (deal.measuresLodgementDate) return "At Post Survey";
if (deal.lodgementStatus) return "At Lodgement";
if (deal.actualMeasuresInstalled || deal.installerHandover) return "Installation Complete";
return "Installation in Progress";
if (hasDesignDoc) return "Installation in Progress";
return "Design in Progress";
}
// -----------------------------------------------------------------------
// Resolve display stage for a single deal
// Maps dealstage ID + coordination/design/install status -> DisplayStage
// Maps dealstage ID + coordination/install status + Retrofit Design Document
// presence -> DisplayStage. Design-document presence is passed in explicitly
// (hasDesignDoc) so this function stays pure — it never reads the doc store.
// -----------------------------------------------------------------------
export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
export function resolveDisplayStage(deal: HubspotDeal, hasDesignDoc = false): DisplayStage {
if (deal.batch === "Removed from Program") {
return "Removed from Program";
}
@ -126,13 +140,10 @@ export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT";
if (raw === "AFTER_ASSESSMENT") {
const afterAssessment = resolveAfterAssessmentStage(
deal.coordinationStatus,
deal.designStatus
);
const afterAssessment = resolveAfterAssessmentStage(deal.coordinationStatus);
if (afterAssessment === "POST_DESIGN") {
return resolvePostDesignStage(deal);
if (afterAssessment === "AFTER_COORDINATION") {
return resolveAfterCoordinationStage(deal, hasDesignDoc);
}
return afterAssessment;
@ -152,10 +163,13 @@ export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
// Classify all deals in a list
// Adds displayStage to each deal
// -----------------------------------------------------------------------
export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] {
export function classifyDeals(
deals: HubspotDeal[],
designDocDealIds: ReadonlySet<string> = new Set()
): ClassifiedDeal[] {
return deals.map((deal) => ({
...deal,
displayStage: resolveDisplayStage(deal),
displayStage: resolveDisplayStage(deal, designDocDealIds.has(deal.dealId)),
}));
}
@ -280,10 +294,11 @@ export function hasMajorConditionIssue(deal: {
// Orchestrates all transformations: classify, group by project, compute stats
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
rawDeals: HubspotDeal[],
designDocDealIds: ReadonlySet<string> = new Set()
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "instructedMeasuresByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);
const classified = classifyDeals(rawDeals, designDocDealIds);
// Group deals by projectCode
const grouped: Record<string, ClassifiedDeal[]> = {};

View file

@ -0,0 +1,298 @@
/**
* Presentational pieces of the programme dashboard (issue #418).
*
* All Server Components the dashboard is a read of already-aggregated
* numbers, so nothing here needs client state, an effect or a query hook.
* These components hold no rules: every number arrives already derived by
* `@/lib/projects/dashboardSummary`, which applies `@/lib/projects/derivations`.
*
* Layout follows the wireframe's three bands (KPI, needs-attention, tables).
* "Awaiting auth", "No access" and notifications are cut, as the issue
* specifies the schema does not carry them.
*/
import Link from "next/link";
import type {
ContractorSummaryRow,
DashboardKpi,
NeedsAttention,
WorkstreamSummaryRow,
} from "@/lib/projects/dashboardSummary";
import { formatRatio } from "@/lib/projects/dashboardSummary";
import type { RecentWorkOrder } from "@/app/repositories/projects/dashboardRepository";
const count = (n: number) => n.toLocaleString("en-GB");
/** Band 1 — the headline programme numbers. */
export function KpiBand({ kpi }: { kpi: DashboardKpi }) {
const tiles = [
{ label: "Total work orders", value: count(kpi.totalOrders) },
{ label: "In progress", value: count(kpi.inProgress) },
{ label: "Completed", value: count(kpi.complete) },
{ label: "Overdue", value: count(kpi.overdue), tone: kpi.overdue > 0 },
{
label: "Required evidence",
value: formatRatio(kpi.evidence.ratio),
hint:
kpi.evidence.ratio === null
? "No requirements configured"
: `${count(kpi.evidence.satisfied)} of ${count(kpi.evidence.required)}`,
},
];
return (
<section aria-label="Programme health" data-testid="dashboard-kpi-band">
<BandHeading>Programme health</BandHeading>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{tiles.map((tile) => (
<div
key={tile.label}
className="rounded-xl border border-gray-200 bg-white px-4 py-4"
>
<p className="text-xs font-medium text-gray-500 mb-1">{tile.label}</p>
<p
className={`text-2xl font-extrabold tracking-tight tabular-nums ${
tile.tone ? "text-red-600" : "text-gray-900"
}`}
>
{tile.value}
</p>
{tile.hint ? (
<p className="text-xs text-gray-400 mt-0.5">{tile.hint}</p>
) : null}
</div>
))}
</div>
</section>
);
}
/** Band 2 — what someone should go and do something about. */
export function NeedsAttentionBand({
needsAttention,
workOrdersHref,
}: {
needsAttention: NeedsAttention;
workOrdersHref: string;
}) {
const items = [
{ label: "Overdue", value: needsAttention.overdue },
{ label: "Missing required docs", value: needsAttention.ordersMissingRequiredDocs },
{ label: "No contractor assigned", value: needsAttention.unassignedContractor },
];
return (
<section aria-label="Needs attention" data-testid="dashboard-needs-attention">
<BandHeading>Needs attention</BandHeading>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{items.map((item) => (
<Link
key={item.label}
href={workOrdersHref}
className={`rounded-xl border px-4 py-4 transition-colors ${
item.value > 0
? "border-amber-200 bg-amber-50 hover:bg-amber-100"
: "border-gray-200 bg-white hover:bg-gray-50"
}`}
>
<p className="text-xs font-medium text-gray-600 mb-1">{item.label}</p>
<p className="text-2xl font-extrabold tracking-tight tabular-nums text-gray-900">
{count(item.value)}
</p>
</Link>
))}
</div>
</section>
);
}
/** Band 3a — orders, completion, overdue and missing docs per workstream. */
export function WorkstreamsTable({ rows }: { rows: WorkstreamSummaryRow[] }) {
return (
<section aria-label="Workstreams" data-testid="dashboard-workstreams">
<BandHeading>Workstreams</BandHeading>
<TableFrame
head={[
{ label: "Name" },
{ label: "Orders", numeric: true },
{ label: "% complete", numeric: true },
{ label: "Overdue", numeric: true },
{ label: "Missing docs", numeric: true },
]}
empty={rows.length === 0 ? "No workstreams configured yet." : null}
>
{rows.map((row) => (
<tr key={row.workstreamId.toString()} className="border-t border-gray-100">
<Td className="font-medium text-gray-900">{row.name}</Td>
<Td numeric>{count(row.orders)}</Td>
<Td numeric>{formatRatio(row.completeRatio)}</Td>
<Td numeric tone={row.overdue > 0}>{count(row.overdue)}</Td>
<Td numeric tone={row.ordersMissingRequiredDocs > 0}>
{count(row.ordersMissingRequiredDocs)}
</Td>
</tr>
))}
</TableFrame>
</section>
);
}
/** Band 3b — the same view sliced by contractor organisation. */
export function ContractorsTable({ rows }: { rows: ContractorSummaryRow[] }) {
return (
<section aria-label="Contractors" data-testid="dashboard-contractors">
<BandHeading>Contractors</BandHeading>
<TableFrame
head={[
{ label: "Contractor" },
{ label: "Orders", numeric: true },
{ label: "Overdue", numeric: true },
{ label: "Docs complete", numeric: true },
]}
empty={rows.length === 0 ? "No work orders issued yet." : null}
>
{rows.map((row) => (
<tr
key={row.organisationId ?? "unassigned"}
className="border-t border-gray-100"
>
<Td
className={
row.organisationId === null
? "font-medium text-gray-500 italic"
: "font-medium text-gray-900"
}
>
{row.name}
</Td>
<Td numeric>{count(row.orders)}</Td>
<Td numeric tone={row.overdue > 0}>{count(row.overdue)}</Td>
<Td numeric>{formatRatio(row.evidence.ratio)}</Td>
</tr>
))}
</TableFrame>
</section>
);
}
/** Band 3c — the most recently touched work orders, linking to the full table. */
export function RecentWorkOrders({
rows,
workOrdersHref,
}: {
rows: RecentWorkOrder[];
workOrdersHref: string;
}) {
return (
<section aria-label="Recent work orders" data-testid="dashboard-recent">
<div className="flex items-baseline justify-between">
<BandHeading>Recent work orders</BandHeading>
<Link
href={workOrdersHref}
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
View all
</Link>
</div>
<TableFrame
head={[
{ label: "Reference" },
{ label: "Property" },
{ label: "Workstream" },
{ label: "Stage" },
{ label: "Forecast end", numeric: true },
]}
empty={rows.length === 0 ? "No work orders yet." : null}
>
{rows.map((row) => (
<tr key={row.id.toString()} className="border-t border-gray-100">
<Td className="font-mono text-xs text-gray-900">{row.reference}</Td>
<Td className="text-gray-700">
{row.propertyAddress ?? "—"}
{row.propertyPostcode ? (
<span className="text-gray-400"> · {row.propertyPostcode}</span>
) : null}
</Td>
<Td className="text-gray-700">{row.workstreamName}</Td>
<Td className="text-gray-700">{row.stageName}</Td>
<Td numeric>{row.forecastEnd ?? "—"}</Td>
</tr>
))}
</TableFrame>
</section>
);
}
function BandHeading({ children }: { children: React.ReactNode }) {
return (
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">
{children}
</h2>
);
}
/** A column header: `numeric` right-aligns it to match its `Td`s. */
interface Column {
label: string;
numeric?: boolean;
}
function TableFrame({
head,
empty,
children,
}: {
head: Column[];
empty: string | null;
children: React.ReactNode;
}) {
if (empty) {
return (
<div className="rounded-xl border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-400">
{empty}
</div>
);
}
return (
<div className="rounded-xl border border-gray-200 bg-white overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left">
{head.map((column) => (
<th
key={column.label}
className={`px-4 py-2.5 text-xs font-semibold text-gray-500 ${
column.numeric ? "text-right" : ""
}`}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
function Td({
children,
numeric,
tone,
className = "",
}: {
children: React.ReactNode;
numeric?: boolean;
tone?: boolean;
className?: string;
}) {
return (
<td
className={`px-4 py-2.5 ${numeric ? "text-right tabular-nums" : ""} ${
tone ? "text-red-600 font-semibold" : ""
} ${className}`}
>
{children}
</td>
);
}

View file

@ -0,0 +1,176 @@
"use client";
import {
assignColumn,
columnMappingRows,
IMPORT_FIELDS,
IMPORT_FIELDS_BY_KEY,
IMPORT_REQUIREMENTS,
summariseColumnMapping,
type ImportColumnMapping,
type ImportFieldKey,
} from "@/lib/projects/import/columnMapping";
import { MappingSelect, MappingStatusIcon } from "./MappingSelect";
/** Fields named by a requirement carry the "required" tag in the picker. */
const REQUIRED_KEYS = new Set<ImportFieldKey>(
IMPORT_REQUIREMENTS.flatMap((requirement) => requirement.keys),
);
/**
* Layer 1 of the mapping step (issue #415): one row per **file column**, each
* pointed at an Ara field, at "don't import", or at nothing yet.
*
* Rows are file columns rather than Ara fields because that is the direction
* the wireframe reads (file Ara) and the direction the user thinks in they
* are looking at their own spreadsheet. Which *fields* are still missing is a
* separate question, answered by the blocker list beneath the table, since a
* missing field has no row of its own to complain from.
*
* Stateless: it renders the mapping it is handed and reports edits upward, so
* the whole screen re-derives from one value.
*/
export function ColumnMappingTable({
headers,
preview,
mapping,
onChange,
}: {
headers: string[];
/** Sample rows, for the "e.g. …" hint under each heading. */
preview: string[][];
mapping: ImportColumnMapping;
onChange: (next: ImportColumnMapping) => void;
}) {
const rows = columnMappingRows(headers, mapping, preview);
const summary = summariseColumnMapping(headers, mapping);
return (
<section
data-testid="column-mapping"
className="rounded-xl border border-gray-200 bg-white overflow-hidden"
>
<header className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-gray-800">Column mapping</h2>
<p className="text-xs text-gray-500 mt-0.5">
Match each column in your file to the field it holds.
</p>
</div>
<span className="rounded-full bg-white border border-gray-200 px-2 py-1 text-xs font-medium text-gray-500">
File Ara
</span>
</header>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-white">
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-gray-500">
<th className="px-4 py-2 w-5/12">File column</th>
<th className="px-4 py-2 w-1/12 text-center">Status</th>
<th className="px-4 py-2 w-6/12">Ara field</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr
key={row.columnIndex}
data-testid="column-mapping-row"
data-status={row.status}
className={
row.status === "needs_review"
? "bg-red-50/40 border-l-4 border-l-red-400"
: ""
}
>
<td className="px-4 py-2.5 align-top">
<div className="font-medium text-gray-800">{row.header}</div>
{row.sample !== "" && (
<div className="text-xs text-gray-500 truncate max-w-[16rem]">
e.g. {row.sample}
</div>
)}
</td>
<td className="px-4 py-2.5 text-center align-top">
<MappingStatusIcon status={row.status} />
</td>
<td className="px-4 py-2 align-top">
<MappingSelect
ariaLabel={`Ara field for the ${row.header} column`}
testId={`column-field-${row.columnIndex}`}
value={row.field ?? (row.status === "ignored" ? "__ignore__" : null)}
invalid={row.status === "needs_review"}
placeholder="Choose a field…"
noneLabel="Not mapped yet"
onChange={(next) =>
onChange(
assignColumn(
mapping,
row.columnIndex,
next === "__ignore__"
? "ignore"
: (next as ImportFieldKey | null),
),
)
}
options={[
...IMPORT_FIELDS.map((field) => {
const heldBy = mapping.fields[field.key];
return {
value: field.key,
label: REQUIRED_KEYS.has(field.key)
? `${field.label} (required)`
: field.label,
// Say so when picking this field would take it off
// another column, rather than silently moving it.
hint:
heldBy !== undefined && heldBy !== row.columnIndex
? `currently ${headers[heldBy]}`
: field.hint,
};
}),
{ value: "__ignore__", label: "Don't import this column" },
]}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{summary.missingRequirements.length > 0 && (
<div
role="alert"
data-testid="column-mapping-blockers"
className="border-t border-red-200 bg-red-50 px-4 py-3"
>
<p className="text-sm font-semibold text-red-900">
{summary.missingRequirements.length} required field
{summary.missingRequirements.length === 1 ? "" : "s"} still to map
</p>
<ul className="mt-1.5 space-y-1">
{summary.missingRequirements.map((requirement) => (
<li key={requirement.id} className="text-xs text-red-800">
<span className="font-semibold">{requirement.label}</span> {" "}
{requirement.reason}
</li>
))}
</ul>
</div>
)}
{summary.complete && summary.unmappedOptionalFields.length > 0 && (
<div className="border-t border-gray-100 bg-gray-50/60 px-4 py-2.5">
<p className="text-xs text-gray-500">
Not mapped (optional):{" "}
{summary.unmappedOptionalFields
.map((key) => IMPORT_FIELDS_BY_KEY[key].label)
.join(", ")}
.
</p>
</div>
)}
</section>
);
}

View file

@ -0,0 +1,242 @@
"use client";
import { useState } from "react";
import {
ArrowLeftIcon,
DocumentTextIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/app/shadcn_components/ui/button";
import {
autoMatchColumns,
summariseColumnMapping,
} from "@/lib/projects/import/columnMapping";
import type { ImportMappingTargets } from "@/lib/projects/import/mappingTargets";
import {
buildImportSession,
sessionMatchesFile,
type ImportSession,
} from "@/lib/projects/import/session";
import {
buildValueMappingModel,
EMPTY_VALUE_MAPPING,
} from "@/lib/projects/import/valueMapping";
import { ColumnMappingTable } from "./ColumnMappingTable";
import { ValueMappingTables } from "./ValueMappingTables";
import type { ParsedImportResponse } from "./ImportUpload";
/** What the mapping step hands to validation (#417) when the user continues. */
export interface MappingHandoff {
session: ImportSession;
/** Rows that will fail validation as mapped — a warning, never a blocker. */
warningRows: number;
}
/**
* Work-order import, step 2 of 3 column mapping and value mapping (#415).
*
* Wireframes: `08-map-columns-rebuilt.html` (value mapping locked) and
* `09-map-columns-workstream-unlocked.html` (unlocked).
*
* Holds the two mapping layers and nothing else. Every figure on the screen
* statuses, counts, which values need review, whether Continue is enabled is
* **derived inline** from `(file, columnMapping, valueMapping, targets)` on each
* render rather than stored, so the tables cannot disagree with the mapping the
* user is looking at. That is also why re-pointing the workstream column
* immediately re-groups the contractor and stage tables: they were never a
* separate copy of the answer.
*
* The initial column mapping is auto-matched once, in the `useState`
* initialiser unless a session for this same file comes back in, which is
* what returning from a later step restores. It is a *suggestion*, so it
* belongs in state (the user edits it) rather than being recomputed each render.
*
* Every edit also reports the whole session upward. That is what "persist the
* mapping alongside the import session" amounts to while the session is
* client-held (ADR-0020): the parent owns the session, this component owns the
* editing of it, and stepping away and back does not lose the work.
*/
export function ImportMapping({
file,
targets,
initialSession,
onSessionChange,
onBack,
onContinue,
}: {
file: ParsedImportResponse;
targets: ImportMappingTargets;
/** A previously-edited session to resume; ignored unless it fits this file. */
initialSession?: ImportSession | null;
/** Called with the full session after every edit, so the parent can keep it. */
onSessionChange?: (session: ImportSession) => void;
onBack: () => void;
onContinue: (handoff: MappingHandoff) => void;
}) {
// A mapping is a set of column indices, so a session from a *different* file
// would map the wrong columns; `sessionMatchesFile` is what refuses it.
const resumed =
initialSession && sessionMatchesFile(initialSession, file)
? initialSession
: null;
const [columnMapping, setColumnMapping] = useState(
() => resumed?.columnMapping ?? autoMatchColumns(file.headers),
);
const [valueMapping, setValueMapping] = useState(
() => resumed?.valueMapping ?? EMPTY_VALUE_MAPPING,
);
function updateColumns(next: typeof columnMapping) {
setColumnMapping(next);
onSessionChange?.(buildImportSession(file, next, valueMapping));
}
function updateValues(next: typeof valueMapping) {
setValueMapping(next);
onSessionChange?.(buildImportSession(file, columnMapping, next));
}
const summary = summariseColumnMapping(file.headers, columnMapping);
const valueModel = buildValueMappingModel(
file.rows,
columnMapping,
valueMapping,
targets,
);
const valuesNeedingReview =
valueModel.needsReview.workstreams +
valueModel.needsReview.contractors +
valueModel.needsReview.stages;
return (
<div data-testid="import-mapping">
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center min-w-0">
<DocumentTextIcon className="h-5 w-5 text-gray-400 mr-2 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-800 truncate">
{file.filename}
</p>
<p className="text-xs text-gray-500">
{file.rowCount.toLocaleString("en-GB")} row
{file.rowCount === 1 ? "" : "s"} · {file.headers.length} column
{file.headers.length === 1 ? "" : "s"}
</p>
</div>
</div>
<div className="flex items-center gap-6">
<Figure label="Mapped" value={summary.matched} />
<Figure
label="Needs review"
value={summary.needsReview + valuesNeedingReview}
tone={
summary.needsReview + valuesNeedingReview > 0 ? "warning" : "plain"
}
/>
</div>
</div>
<div className="mt-4">
<ColumnMappingTable
headers={file.headers}
preview={file.preview}
mapping={columnMapping}
onChange={updateColumns}
/>
</div>
<ValueMappingTables
model={valueModel}
targets={targets}
valueMapping={valueMapping}
onChange={updateValues}
// Locked until every required column is mapped — until then we do not
// reliably know which column holds the workstream, and every value
// table hangs off that one answer.
locked={!summary.complete}
/>
{summary.complete && valueModel.warningRows > 0 && (
<div
role="status"
data-testid="value-mapping-warning"
className="mt-4 flex items-start rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"
>
<ExclamationTriangleIcon className="h-5 w-5 mr-2.5 shrink-0 text-amber-500" />
<div>
<p className="font-semibold">
{valueModel.warningRows.toLocaleString("en-GB")} of{" "}
{file.rowCount.toLocaleString("en-GB")} rows will fail validation
as mapped
</p>
<p className="mt-0.5 text-amber-800">
Rows with an unmapped workstream, a missing or unmapped contractor,
or a stage that matches nothing cannot be imported. You can
continue and deal with them at validation, or map the values above
first.
</p>
</div>
</div>
)}
<div className="mt-6 flex items-center justify-between">
<Button variant="outline" onClick={onBack} data-testid="mapping-back">
<ArrowLeftIcon className="h-4 w-4 mr-1.5" />
Back to upload
</Button>
<div className="flex items-center gap-3">
{!summary.complete && (
<p className="text-xs text-red-700 max-w-xs text-right">
Map{" "}
{summary.missingRequirements.map((r) => r.label).join(", ")} to
continue.
</p>
)}
<Button
data-testid="mapping-continue"
disabled={!summary.complete}
onClick={() =>
onContinue({
session: buildImportSession(file, columnMapping, valueMapping),
warningRows: valueModel.warningRows,
})
}
>
Continue to validation
</Button>
</div>
</div>
</div>
);
}
/** One figure in the file summary card. */
function Figure({
label,
value,
tone = "plain",
}: {
label: string;
value: number;
tone?: "plain" | "warning";
}) {
return (
<div className="text-right">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
{label}
</p>
<p
data-testid={`mapping-figure-${label.toLowerCase().replace(/\s+/g, "-")}`}
className={[
"text-xl font-bold tabular-nums",
tone === "warning" ? "text-red-600" : "text-gray-900",
].join(" ")}
>
{value.toLocaleString("en-GB")}
</p>
</div>
);
}

View file

@ -0,0 +1,76 @@
import Link from "next/link";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import type { ImportReadiness } from "@/lib/projects/import/readiness";
/** Human-readable phrase for each unmet requirement. */
const GAP_LABEL = {
stages: "no stages",
contractor: "no contractor assigned",
} as const;
/**
* Shown in place of the drop zone when the project is not yet set up enough to
* import against (ADR-0019). A plain server component it renders the
* `ImportReadiness` the page already loaded, so no client state is involved.
*
* It deliberately explains *which* workstreams are incomplete and *why*, rather
* than a bare "not ready", so the user knows exactly what to fix before
* returning. `setupHref` points at the project's configuration; the concrete
* per-step wizard routes (select workstreams / configure stages / assign
* contractors) land in other issues, so the page passes a single sensible
* destination rather than deep-linking each gap.
*/
export function ImportSetupIncomplete({
readiness,
setupHref,
}: {
readiness: ImportReadiness;
setupHref: string;
}) {
return (
<div
data-testid="import-setup-incomplete"
className="rounded-2xl border border-amber-200 bg-amber-50/70 p-6"
>
<div className="flex items-start">
<ExclamationTriangleIcon className="h-6 w-6 mr-3 shrink-0 text-amber-500" />
<div className="min-w-0">
<h2 className="text-lg font-semibold text-amber-900">
Finish project setup first
</h2>
<p className="mt-1 text-sm text-amber-800">
{readiness.hasWorkstreams
? "Every workstream needs at least one stage and an assigned contractor before you can import work orders against it."
: "This project has no workstreams yet. Add the workstreams it covers, give each a stage ladder and a contractor, then come back to import."}
</p>
{readiness.incompleteWorkstreams.length > 0 && (
<ul className="mt-4 space-y-2" data-testid="import-incomplete-list">
{readiness.incompleteWorkstreams.map((w) => (
<li
key={String(w.projectWorkstreamId)}
className="flex items-center justify-between rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm"
>
<span className="font-medium text-gray-800">
{w.workstreamName}
</span>
<span className="text-xs font-medium text-amber-700">
{w.missing.map((gap) => GAP_LABEL[gap]).join(" · ")}
</span>
</li>
))}
</ul>
)}
<Link
href={setupHref}
data-testid="import-setup-link"
className="mt-5 inline-flex items-center rounded-md bg-brandblue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brandblue/90"
>
Go to project setup
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,380 @@
"use client";
import { DragEvent, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
ArrowDownTrayIcon,
ArrowPathIcon,
CheckCircleIcon,
CloudArrowUpIcon,
DocumentTextIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/app/shadcn_components/ui/button";
import {
ACCEPTED_IMPORT_EXTENSIONS,
hasAcceptedImportExtension,
MAX_IMPORT_FILE_BYTES,
} from "@/lib/projects/import/parse";
import type { ImportMappingTargets } from "@/lib/projects/import/mappingTargets";
import type { ImportSession } from "@/lib/projects/import/session";
import { ImportMapping, type MappingHandoff } from "./ImportMapping";
/** The parse route's response — the whole file, not just the preview. */
export interface ParsedImportResponse {
filename: string;
headers: string[];
rows: string[][];
rowCount: number;
preview: string[][];
}
const MAX_MB = MAX_IMPORT_FILE_BYTES / (1024 * 1024);
/** Which of the wizard's steps this component is showing. */
type ImportStep = "upload" | "mapping" | "mapped";
/**
* Work-order import drop zone (issue #414) and the step-2 mapping screen it
* hands off to (issue #415).
*
* Uploads the file to the parse route and renders the returned headers and
* preview. The full `rows` array stays here in `parse.data` the mapping
* (#415), validation (#416) and commit (#417) steps read it from this
* component's state rather than re-fetching or re-parsing, which is why the
* route hands back every row and not only the preview.
*
* The step is client state rather than a route because the parsed file lives
* here: navigating to a `/import/mapping` URL would drop the rows on the floor
* and force a re-upload on every Back. Going back to the upload step therefore
* keeps the parsed file, and only choosing a different file discards it.
*/
export function ImportUpload({
projectId,
targets,
}: {
projectId: string;
/** The project's workstreams, stages and contractors — the value-mapping targets. */
targets: ImportMappingTargets;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
// Size/extension failures caught before the upload; the route re-checks both.
const [clientError, setClientError] = useState<string | null>(null);
const [step, setStep] = useState<ImportStep>("upload");
// The import session: the file's mapping, held here so stepping back to the
// drop zone and forward again does not throw the user's work away. Nothing is
// written server-side until commit (#417) — see ADR-0020.
const [session, setSession] = useState<ImportSession | null>(null);
// Set when the user finishes mapping — the artefact validation (#417) takes.
const [handoff, setHandoff] = useState<MappingHandoff | null>(null);
const parse = useMutation<ParsedImportResponse, Error, File>({
mutationFn: async (file: File) => {
const form = new FormData();
form.append("file", file);
const res = await fetch(`/api/projects/${projectId}/import/parse`, {
method: "POST",
body: form,
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't read that file");
return body;
},
});
function onFile(file: File) {
setClientError(null);
parse.reset();
setFileName(file.name);
// A different file means a different set of columns, so any mapping made
// against the old one is meaningless (see `sessionMatchesFile`).
setStep("upload");
setSession(null);
setHandoff(null);
if (!hasAcceptedImportExtension(file.name)) {
setClientError(
`That file type isn't supported — upload a ${ACCEPTED_IMPORT_EXTENSIONS.join(", ")} file.`,
);
return;
}
if (file.size > MAX_IMPORT_FILE_BYTES) {
setClientError(
`That file is larger than ${MAX_MB}MB — split it into smaller files.`,
);
return;
}
parse.mutate(file);
}
function handleDragOver(e: DragEvent<HTMLLabelElement>) {
e.preventDefault();
setIsDragging(true);
}
function handleDrop(e: DragEvent<HTMLLabelElement>) {
e.preventDefault();
setIsDragging(false);
const dropped = e.dataTransfer.files?.[0];
if (dropped) onFile(dropped);
}
function reset() {
setFileName(null);
setClientError(null);
parse.reset();
setStep("upload");
setSession(null);
setHandoff(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
const error = clientError ?? (parse.isError ? parse.error.message : null);
const parsed = parse.data;
if (parsed && step === "mapping") {
return (
<ImportMapping
// Keyed on the file so a fresh upload starts from its own auto-match
// rather than inheriting the previous file's mapping state.
key={`${parsed.filename}:${parsed.rowCount}`}
file={parsed}
targets={targets}
initialSession={session}
onSessionChange={setSession}
onBack={() => setStep("upload")}
onContinue={(next) => {
setHandoff(next);
setStep("mapped");
}}
/>
);
}
if (parsed && step === "mapped" && handoff) {
return (
<MappingSaved
handoff={handoff}
onEdit={() => setStep("mapping")}
onStartOver={reset}
/>
);
}
return (
<div data-testid="import-upload">
<label
onDragOver={handleDragOver}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={[
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed px-6 py-12 text-center cursor-pointer transition-colors",
isDragging
? "border-brandblue bg-blue-50"
: "border-gray-200 bg-gray-50/60 hover:border-gray-300 hover:bg-gray-50",
].join(" ")}
>
<input
ref={fileInputRef}
type="file"
className="sr-only"
accept={ACCEPTED_IMPORT_EXTENSIONS.join(",")}
data-testid="import-file-input"
onChange={(e) => {
const picked = e.target.files?.[0];
if (picked) onFile(picked);
}}
/>
{parse.isLoading ? (
<ArrowPathIcon className="h-10 w-10 text-brandblue animate-spin mb-3" />
) : (
<CloudArrowUpIcon className="h-10 w-10 text-gray-400 mb-3" />
)}
<p className="text-base font-semibold text-gray-800">
{parse.isLoading
? `Reading ${fileName}`
: "Drag and drop your file here"}
</p>
<p className="text-xs text-gray-500 mt-1">
Supported formats: {ACCEPTED_IMPORT_EXTENSIONS.join(", ")}. Maximum
file size {MAX_MB}MB.
</p>
<span className="mt-4 inline-flex items-center rounded-md bg-white border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm">
Browse files
</span>
</label>
<div className="mt-3 flex justify-center">
<a
href={`/api/projects/${projectId}/import/template`}
className="inline-flex items-center text-sm font-medium text-brandblue hover:underline"
data-testid="import-download-template"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-1.5" />
Download template
</a>
</div>
{error && (
<div
role="alert"
data-testid="import-error"
className="mt-6 flex items-start rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-900"
>
<ExclamationTriangleIcon className="h-5 w-5 mr-2.5 shrink-0 text-red-500" />
<div>
<p className="font-semibold">Couldn&apos;t import that file</p>
<p className="mt-0.5 text-red-800">{error}</p>
<button
type="button"
onClick={reset}
className="mt-2 text-xs font-semibold underline"
>
Try another file
</button>
</div>
</div>
)}
{parsed && (
<div className="mt-8" data-testid="import-preview">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center min-w-0">
<DocumentTextIcon className="h-5 w-5 text-gray-400 mr-2 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-800 truncate">
{parsed.filename}
</p>
<p className="text-xs text-gray-500">
{parsed.rowCount.toLocaleString("en-GB")} row
{parsed.rowCount === 1 ? "" : "s"} ·{" "}
{parsed.headers.length} column
{parsed.headers.length === 1 ? "" : "s"}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={reset}>
Choose a different file
</Button>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200">
<table className="min-w-full text-sm">
<thead className="bg-gray-50">
<tr>
{parsed.headers.map((header, i) => (
<th
key={`${header}-${i}`}
className="px-3 py-2 text-left text-xs font-semibold text-gray-600 whitespace-nowrap"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{parsed.preview.map((row, rowIndex) => (
<tr key={rowIndex} className="bg-white">
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
className="px-3 py-2 text-gray-700 whitespace-nowrap"
>
{cell === "" ? (
<span className="text-gray-300"></span>
) : (
cell
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{parsed.rowCount > parsed.preview.length && (
<p className="mt-2 text-xs text-gray-500">
Showing the first {parsed.preview.length} of{" "}
{parsed.rowCount.toLocaleString("en-GB")} rows.
</p>
)}
<div className="mt-6 flex justify-end">
<Button
data-testid="import-continue-to-mapping"
onClick={() => setStep("mapping")}
>
Continue to mapping
</Button>
</div>
</div>
)}
</div>
);
}
/**
* The end of step 2: the mapping is settled and held in the import session,
* waiting for validation and commit (#417) to consume it.
*
* It states what was captured rather than claiming anything was saved to the
* server, because nothing was an import writes nothing until commit
* (ADR-0020). Both ways back are offered: edit the mapping, or start again with
* a different file.
*/
function MappingSaved({
handoff,
onEdit,
onStartOver,
}: {
handoff: MappingHandoff;
onEdit: () => void;
onStartOver: () => void;
}) {
const { session, warningRows } = handoff;
const mappedFields = Object.keys(session.columnMapping.fields).length;
return (
<div
data-testid="import-mapping-saved"
className="rounded-2xl border border-gray-200 bg-white p-6"
>
<div className="flex items-start">
<CheckCircleIcon className="h-6 w-6 mr-3 shrink-0 text-green-500" />
<div className="min-w-0">
<h2 className="text-lg font-semibold text-gray-900">
Mapping complete
</h2>
<p className="mt-1 text-sm text-gray-600">
{mappedFields} field{mappedFields === 1 ? "" : "s"} mapped across{" "}
{session.rowCount.toLocaleString("en-GB")} row
{session.rowCount === 1 ? "" : "s"} of {session.filename}. Nothing
has been written yet validation and commit arrive in #417.
</p>
{warningRows > 0 && (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{warningRows.toLocaleString("en-GB")} row
{warningRows === 1 ? "" : "s"} still carry values that map to
nothing and will fail validation.
</p>
)}
<div className="mt-5 flex gap-3">
<Button variant="outline" onClick={onEdit} data-testid="mapping-edit">
Edit mapping
</Button>
<Button variant="outline" onClick={onStartOver}>
Choose a different file
</Button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,126 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
/**
* The one picker both mapping layers use (issue #415): "this file thing is that
* Ara thing", with an explicit way to say *neither*.
*
* Radix reserves the empty string as a value, so "not mapped" travels as a
* sentinel and is translated at this boundary callers only ever see
* `string | null`, which is what the mapping modules store.
*/
const NONE = "__none__";
export interface MappingOption {
value: string;
label: string;
/** Muted second line — what a column is currently used for, a contractor's org, … */
hint?: string;
}
export function MappingSelect({
value,
options,
onChange,
noneLabel,
placeholder,
disabled = false,
invalid = false,
testId,
ariaLabel,
}: {
/** The chosen option's value, or null when nothing is mapped. */
value: string | null;
options: MappingOption[];
/** Called with null when the user picks the "none" entry. */
onChange: (value: string | null) => void;
/** What choosing nothing means here — "Don't import", "Leave unmapped", … */
noneLabel: string;
placeholder: string;
disabled?: boolean;
/** Draws the needs-review treatment the wireframe gives unmapped rows. */
invalid?: boolean;
testId?: string;
ariaLabel: string;
}) {
return (
<Select
value={value ?? NONE}
onValueChange={(next) => onChange(next === NONE ? null : next)}
disabled={disabled}
>
<SelectTrigger
aria-label={ariaLabel}
data-testid={testId}
className={[
"h-9 text-sm",
invalid ? "border-red-300 text-red-700" : "",
].join(" ")}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
<span className="text-gray-500">{noneLabel}</span>
</SelectItem>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="font-medium">{option.label}</span>
{option.hint && (
<span className="ml-2 text-xs text-gray-500">{option.hint}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** The wireframe's per-row status affordance, shared by both tables. */
export function MappingStatusIcon({
status,
}: {
status: "matched" | "needs_review" | "ignored";
}) {
if (status === "matched") {
return (
<span
title="Matched"
data-testid="mapping-status-matched"
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-green-100 text-green-700 text-xs font-bold"
aria-label="Matched"
>
</span>
);
}
if (status === "ignored") {
return (
<span
title="Not imported"
data-testid="mapping-status-ignored"
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-100 text-gray-400 text-xs font-bold"
aria-label="Not imported"
>
</span>
);
}
return (
<span
title="Needs review"
data-testid="mapping-status-needs-review"
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-red-100 text-red-700 text-xs font-bold"
aria-label="Needs review"
>
?
</span>
);
}

View file

@ -0,0 +1,390 @@
"use client";
import { LockClosedIcon } from "@heroicons/react/24/outline";
import type { ImportMappingTargets } from "@/lib/projects/import/mappingTargets";
import {
setContractorValue,
setStageValue,
setWorkstreamValue,
type ImportValueMapping,
type ScopedValueEntry,
type ValueMappingModel,
} from "@/lib/projects/import/valueMapping";
import { MappingSelect, MappingStatusIcon } from "./MappingSelect";
/**
* Layer 2 of the mapping step (issue #415): the file's *values* against the
* project's own workstreams, contractors and stages.
*
* Three tables rather than one, because they answer different questions at
* different scopes and the two lower ones are scoped **by** the first:
* contractors and stages are offered per workstream, since an assignment
* belongs to a workstream and a ladder belongs to a workstream (ADR-0019).
* That is why they are grouped under workstream headings rather than listed
* flat: the same contractor name under two workstreams is two questions.
*
* Locked until column mapping is complete, exactly as wireframe 08 shows
* there is nothing to list until we know which column holds the workstream.
*/
export function ValueMappingTables({
model,
targets,
valueMapping,
onChange,
locked,
}: {
model: ValueMappingModel;
targets: ImportMappingTargets;
valueMapping: ImportValueMapping;
onChange: (next: ImportValueMapping) => void;
locked: boolean;
}) {
const contractorOptions = (entry: ScopedValueEntry) =>
(targets.workstreams.find((w) => w.id === entry.workstreamId)?.contractors ?? [])
.map((contractor) => ({ value: contractor.id, label: contractor.name }));
const stageOptions = (entry: ScopedValueEntry) =>
(targets.workstreams.find((w) => w.id === entry.workstreamId)?.stages ?? []).map(
(stage, index) => ({
value: stage.id,
label: stage.name,
hint: index === 0 ? "first stage" : undefined,
}),
);
return (
<section data-testid="value-mapping" className="relative">
{locked && (
<div
data-testid="value-mapping-locked"
className="absolute inset-0 z-20 flex items-start justify-center rounded-xl bg-white/80 backdrop-blur-[2px] pt-16"
>
<div className="rounded-xl border border-gray-200 bg-white px-6 py-5 text-center shadow-sm">
<LockClosedIcon className="mx-auto h-6 w-6 text-gray-400 mb-2" />
<p className="text-sm font-semibold text-gray-800">
Complete column mapping to map cell values
</p>
<p className="mt-1 text-xs text-gray-500 max-w-xs">
We can only list the values in your file once we know which column
each field comes from.
</p>
</div>
</div>
)}
<div className={locked ? "pointer-events-none select-none opacity-40" : ""}>
<ValuePanel
title="Workstream values"
subtitle="Each distinct value in your workstream column, matched to one of this project's workstreams."
testId="workstream-value-mapping"
valueHeader="File value"
targetHeader="Ara workstream"
empty={
model.hasWorkstreamColumn
? "No workstream values found in the file."
: "Map the workstream column first."
}
notes={[
model.blankRows.workstream > 0
? `${rows(model.blankRows.workstream)} have no workstream — those rows will fail validation.`
: null,
]}
>
{model.workstreams.map((entry) => (
<ValueRow
key={entry.key}
value={entry.value}
rowCount={entry.rowCount}
status={entry.status}
matchedBy={entry.matchedBy}
>
<MappingSelect
ariaLabel={`Ara workstream for the value ${entry.value}`}
testId={`workstream-value-${entry.key}`}
value={entry.targetId}
invalid={entry.status === "needs_review"}
placeholder="Select workstream…"
noneLabel="Leave unmapped"
disabled={locked}
onChange={(next) =>
onChange(setWorkstreamValue(valueMapping, entry.key, next))
}
options={targets.workstreams.map((workstream) => ({
value: workstream.id,
label: workstream.name,
}))}
/>
</ValueRow>
))}
</ValuePanel>
<ScopedPanel
title="Contractor values"
subtitle="Contractors are offered per workstream — only the companies assigned to that workstream can receive its work."
testId="contractor-value-mapping"
targetHeader="Assigned contractor"
entries={model.contractors}
targets={targets}
placeholder="Select contractor…"
noneTargetsNote="No contractor is assigned to this workstream, so its rows cannot be imported."
optionsFor={contractorOptions}
onSelect={(entry, next) =>
onChange(setContractorValue(valueMapping, entry.key, next))
}
locked={locked}
empty={
model.hasContractorColumn
? "No contractor values to map yet."
: "Map the contractor column first."
}
notes={[
model.blankRows.contractor > 0
? `${rows(model.blankRows.contractor)} have no contractor — those rows will fail validation.`
: null,
model.unscopedRows > 0
? `${rows(model.unscopedRows)} are waiting on their workstream value — map it above and their contractors appear here.`
: null,
]}
/>
<ScopedPanel
title="Stage values"
subtitle="Stages are offered per workstream, from that workstream's own ladder."
testId="stage-value-mapping"
targetHeader="Workstream stage"
entries={model.stages}
targets={targets}
placeholder="Select stage…"
noneTargetsNote="This workstream has no stages configured."
optionsFor={stageOptions}
onSelect={(entry, next) =>
onChange(setStageValue(valueMapping, entry.key, next))
}
locked={locked}
empty={
model.hasStageColumn
? "No stage values in the file — every row will start at its workstream's first stage."
: "No stage column mapped — every row will start at its workstream's first stage."
}
notes={[
model.blankRows.stage > 0
? `${rows(model.blankRows.stage)} have no stage — they will start at their workstream's first stage.`
: null,
model.needsReview.stages > 0
? "A stage that matches nothing is an error — leave the cell blank instead if the work has not started."
: null,
]}
/>
</div>
</section>
);
}
/** "1 row" / "1,248 rows", en-GB grouped. */
function rows(count: number): string {
return `${count.toLocaleString("en-GB")} row${count === 1 ? "" : "s"}`;
}
/** The shared chrome of a value table: header, column headings, notes. */
function ValuePanel({
title,
subtitle,
testId,
valueHeader = "File value",
targetHeader,
notes,
empty,
children,
}: {
title: string;
subtitle: string;
testId: string;
valueHeader?: string;
targetHeader: string;
notes: Array<string | null>;
empty: string;
children: React.ReactNode;
}) {
const hasRows = Array.isArray(children) ? children.length > 0 : Boolean(children);
const shown = notes.filter((note): note is string => note !== null);
return (
<div
data-testid={testId}
className="mt-4 rounded-xl border border-gray-200 bg-white overflow-hidden"
>
<header className="border-b border-gray-200 bg-gray-50 px-4 py-3">
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
<p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>
</header>
{hasRows ? (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-gray-500">
<th className="px-4 py-2 w-5/12">{valueHeader}</th>
<th className="px-4 py-2 w-1/12 text-center">Status</th>
<th className="px-4 py-2 w-6/12">{targetHeader}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">{children}</tbody>
</table>
</div>
) : (
<p className="px-4 py-6 text-sm text-gray-500">{empty}</p>
)}
{shown.length > 0 && (
<div className="border-t border-gray-100 bg-gray-50/60 px-4 py-2.5 space-y-1">
{shown.map((note) => (
<p key={note} className="text-xs text-gray-600">
{note}
</p>
))}
</div>
)}
</div>
);
}
/** One value row: the file's text, its row count, its status, its picker. */
function ValueRow({
value,
rowCount,
status,
matchedBy,
scope,
children,
}: {
value: string;
rowCount: number;
status: "matched" | "needs_review";
matchedBy: "user" | "auto" | null;
/** The workstream a contractor/stage value sits under, when it has one. */
scope?: string;
children: React.ReactNode;
}) {
return (
<tr
data-testid="value-mapping-row"
data-status={status}
className={
status === "needs_review" ? "bg-red-50/40 border-l-4 border-l-red-400" : ""
}
>
<td className="px-4 py-2.5 align-top">
<span className="inline-block rounded bg-gray-100 px-2 py-0.5 font-medium text-gray-800">
{value}
</span>
<div className="text-xs text-gray-500 mt-1">
{rows(rowCount)}
{scope && <span className="ml-1.5 text-gray-400">· {scope}</span>}
{matchedBy === "auto" && (
<span className="ml-1.5 text-gray-400">· auto-matched</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-center align-top">
<MappingStatusIcon status={status} />
</td>
<td className="px-4 py-2 align-top">{children}</td>
</tr>
);
}
/**
* A contractor or stage table: the same rows, grouped under the workstream they
* are scoped to, so it is never ambiguous *which* "Acme" is being mapped.
*/
function ScopedPanel({
title,
subtitle,
testId,
targetHeader,
entries,
targets,
optionsFor,
onSelect,
placeholder,
noneTargetsNote,
locked,
notes,
empty,
}: {
title: string;
subtitle: string;
testId: string;
targetHeader: string;
entries: ScopedValueEntry[];
targets: ImportMappingTargets;
optionsFor: (entry: ScopedValueEntry) => Array<{
value: string;
label: string;
hint?: string;
}>;
onSelect: (entry: ScopedValueEntry, next: string | null) => void;
placeholder: string;
noneTargetsNote: string;
locked: boolean;
notes: Array<string | null>;
empty: string;
}) {
// Grouped in the targets' own (alphabetical) order so the grouping does not
// reshuffle as values are mapped; within a group the biggest value leads.
const groups = targets.workstreams
.map((workstream) => ({
workstream,
entries: entries.filter((entry) => entry.workstreamId === workstream.id),
}))
.filter((group) => group.entries.length > 0);
return (
<ValuePanel
title={title}
subtitle={subtitle}
testId={testId}
targetHeader={targetHeader}
notes={notes}
empty={empty}
>
{groups.flatMap((group) => [
<tr key={`group-${group.workstream.id}`} className="bg-gray-50">
<td
colSpan={3}
className="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500"
>
{group.workstream.name}
{group.entries[0].noTargets && (
<span className="ml-2 font-normal normal-case text-red-700">
{noneTargetsNote}
</span>
)}
</td>
</tr>,
...group.entries.map((entry) => (
<ValueRow
key={entry.key}
value={entry.value}
rowCount={entry.rowCount}
status={entry.status}
matchedBy={entry.matchedBy}
scope={group.workstream.name}
>
<MappingSelect
ariaLabel={`${targetHeader} for the value ${entry.value} in ${group.workstream.name}`}
testId={`scoped-value-${entry.key}`}
value={entry.targetId}
invalid={entry.status === "needs_review"}
placeholder={placeholder}
noneLabel="Leave unmapped"
disabled={locked || entry.noTargets}
onChange={(next) => onSelect(entry, next)}
options={optionsFor(entry)}
/>
</ValueRow>
)),
])}
</ValuePanel>
);
}

View file

@ -0,0 +1,132 @@
import { notFound } from "next/navigation";
import { requireProjectAccess } from "../../guards";
import { canManageProject } from "@/lib/projects/authz";
import { loadImportReadiness } from "@/lib/projects/import/readiness";
import {
EMPTY_MAPPING_TARGETS,
loadMappingTargets,
} from "@/lib/projects/import/mappingTargets";
import { ImportUpload } from "./components/ImportUpload";
import { ImportSetupIncomplete } from "./components/ImportSetupIncomplete";
export const metadata = {
title: "Import programme data | Ara",
};
/**
* Work-order import, steps 1 and 2 of 3 upload and parse (issue #414), then
* column mapping and value mapping (issue #415).
*
* Wireframes: `07-import-upload.html`, then `08-map-columns-rebuilt.html` and
* `09-map-columns-workstream-unlocked.html`. Both steps live behind this one
* route because the parsed file is held client-side (ADR-0020) a separate
* mapping URL would drop it. Validation and commit (#417) follow; this page
* still writes nothing.
*
* Only internal and client-org managers may import, so this checks
* `canManageProject` rather than settling for the layout's view-level guard
* a contractor with legitimate access to the project must not see the drop
* zone at all, not merely have their upload rejected by the route handler.
*
* The page is also **setup-gated** (ADR-0019): unless every selected workstream
* has a stage and a contractor, the drop zone is replaced by a "finish setup"
* state. The gate applies regardless of entry point this route is reachable
* standalone from its own nav button today, and #413 will additionally route
* into it as the final step of the setup wizard, so it must not assume it is
* only ever reached once setup is known-complete.
*/
/**
* Where the "finish setup" state sends the user. The concrete per-step wizard
* routes (select workstreams / configure stages / assign contractors) are
* owned by other issues; this points at the project's configuration home, and
* #413 can narrow it to a deep link when those routes firm up.
*/
function setupHref(projectId: string): string {
return `/projects/${projectId}/settings`;
}
export default async function ImportPage(props: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await props.params;
// The guard resolves the user, parses the id and enforces *view* access,
// 404-ing a junk id or a project outside the user's organisation.
const { user, project } = await requireProjectAccess(projectId);
// Import is stricter than viewing: only internal and client-org managers may
// import, so a contractor with legitimate view access must not see the drop
// zone at all. 404 rather than 403, so project existence stays private.
if (!canManageProject(user, project)) notFound();
// Read-only: gather whether the project is set up enough to import against,
// and — when it is — what the file's values may be mapped to. The targets are
// loaded here, on the server, rather than fetched by the mapping step: they
// are the project's own configuration, they cannot change mid-import, and a
// gated project has nothing to map to anyway.
const readiness = await loadImportReadiness(project.id);
const targets = readiness.ready
? await loadMappingTargets(project.id)
: EMPTY_MAPPING_TARGETS;
return (
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="mb-8">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Work-order import · Steps 12 of 3
</p>
<h1
className="text-3xl font-extrabold text-gray-900 tracking-tight"
data-testid="import-heading"
>
Import programme data
</h1>
<p className="text-sm text-gray-500 mt-2 max-w-2xl">
Upload a file listing which workstreams each property takes and which
contractor delivers each one row per property and workstream. A
property taking Windows and Doors appears twice, sharing its
identifier.
</p>
</div>
{readiness.ready ? (
<ImportUpload projectId={projectId} targets={targets} />
) : (
<ImportSetupIncomplete
readiness={readiness}
setupHref={setupHref(projectId)}
/>
)}
<div className="mt-10 grid gap-4 sm:grid-cols-3">
{[
{
title: "Upload & parse",
body: "We read your file and show you the columns and a sample of the rows.",
},
{
title: "Column mapping",
body: "Match your headings to the fields we expect.",
},
{
title: "Validate & commit",
body: "Review what will be created before anything is written.",
},
].map((step, i) => (
<div
key={step.title}
className="rounded-xl border border-gray-100 bg-gray-50/60 p-4"
>
<p className="text-xs font-semibold text-gray-400 mb-1">
Step {i + 1}
</p>
<p className="text-sm font-semibold text-gray-800 mb-1">
{step.title}
</p>
<p className="text-xs text-gray-500 leading-relaxed">{step.body}</p>
</div>
))}
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
import * as React from "react";
import { ProjectsNav, type ProjectsNavItem } from "../components/ProjectsNav";
import { requireProjectAccess } from "../authz";
import { requireProjectAccess } from "../guards";
function projectNav(projectId: string): ProjectsNavItem[] {
const base = `/projects/${projectId}`;

View file

@ -1,25 +1,67 @@
import { requireProjectAccess } from "../authz";
import { notFound } from "next/navigation";
import { requireProjectAccess } from "../guards";
import { loadDashboardData } from "@/app/repositories/projects/dashboardRepository";
import { buildStageLadders } from "@/lib/projects/derivations";
import { summariseDashboard } from "@/lib/projects/dashboardSummary";
import {
ContractorsTable,
KpiBand,
NeedsAttentionBand,
RecentWorkOrders,
WorkstreamsTable,
} from "./components/DashboardBands";
export const metadata = {
title: "Project dashboard | Ara",
};
/** How many work orders the "Recent work orders" band shows. */
const RECENT_LIMIT = 10;
/**
* Project dashboard shell.
* Programme dashboard (issue #418) a Server Component over SQL aggregates.
*
* Renders the frame only. The Workstream / Stage / Work order content is built
* in later issues, once the visibility rule (#408) and the workstream +
* project_type seed data (#407) are in place.
* The numbers here are grouped counts computed in the database, then folded by
* the pure `@/lib/projects/dashboardSummary` using the shared rules in
* `@/lib/projects/derivations`. Nothing on this page re-states what *complete*,
* *overdue* or *evidence completeness* mean that is the whole point of the
* derivations module, which #419's work-orders table reuses so the two surfaces
* reconcile.
*
* Note the single `asOf` below: it is passed to both the SQL aggregate (bound
* as a date parameter, not `CURRENT_DATE`) and the JS rules, so every overdue
* number on the page is measured against the same day even if the render
* straddles midnight.
*
* Authorization runs through the route guard in `../guards` (#409, #439),
* which resolves the user, parses the `[projectId]` segment and enforces
* visibility a junk id or a project the user may not see becomes a 404
* inside the guard. This page adds no permission logic of its own; it reads
* the guaranteed-valid `project.id` back from the guard rather than parsing the
* segment a second time.
*/
export default async function ProjectDashboardPage(props: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await props.params;
await requireProjectAccess(projectId);
const { project } = await requireProjectAccess(projectId);
const asOf = new Date();
const data = await loadDashboardData(project.id, asOf, RECENT_LIMIT);
if (!data) notFound();
const ladders = buildStageLadders(data.stages);
const summary = summariseDashboard(
ladders,
data.groups,
new Map(data.workstreams.map((w) => [w.id, w.name])),
);
const workOrdersHref = `/projects/${projectId}/work-orders`;
return (
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="mb-8">
<div className="max-w-6xl mx-auto px-6 py-10 space-y-10">
<header>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Ara Projects
</p>
@ -27,19 +69,19 @@ export default async function ProjectDashboardPage(props: {
className="text-3xl font-extrabold text-gray-900 tracking-tight"
data-testid="project-dashboard-heading"
>
Project dashboard
{data.project.name}
</h1>
<p className="text-sm text-gray-500 mt-1 font-mono">{projectId}</p>
</div>
<p className="text-sm text-gray-500 mt-1">Programme dashboard</p>
</header>
<div className="py-16 border-2 border-dashed border-gray-200 rounded-2xl text-center">
<p className="text-base font-semibold text-gray-700 mb-1">
Dashboard coming soon
</p>
<p className="text-sm text-gray-400">
Workstreams, stages and work orders will appear here.
</p>
</div>
<KpiBand kpi={summary.kpi} />
<NeedsAttentionBand
needsAttention={summary.needsAttention}
workOrdersHref={workOrdersHref}
/>
<WorkstreamsTable rows={summary.workstreams} />
<ContractorsTable rows={summary.contractors} />
<RecentWorkOrders rows={data.recent} workOrdersHref={workOrdersHref} />
</div>
);
}

View file

@ -0,0 +1,279 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import {
projectDetailsToFormValues,
updateProjectSchema,
type ProjectDetails,
type UpdateProjectFormValues,
} from "@/lib/projects/updateProject";
import type { SelectOption } from "@/lib/projects/createProject";
import { DateInput } from "@/app/components/DateInput";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/app/shadcn_components/ui/form";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
/**
* The HTTP call, kept outside the component so the component contains no
* `fetch` TanStack Query owns the request lifecycle. Route handlers answer
* errors as `{ error: string }`, so a failure surfaces the server's own message.
*/
async function updateProject(
projectId: string,
values: UpdateProjectFormValues,
): Promise<{ id: string }> {
const response = await fetch(`/api/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(body?.error ?? "Could not save the project details");
}
return body;
}
export interface ProjectDetailsFormProps {
details: ProjectDetails;
projectTypes: SelectOption[];
}
/**
* Edit a project's details from the settings hub (issue #444).
*
* The same six fields the create-project modal collects, minus the owning
* organisation re-homing a project would change who can see it, so it is
* shown read-only rather than offered as an edit (see `updateProjectSchema`).
* The resolver is `updateProjectSchema`, which is derived from
* `createProjectSchema`, so the two forms validate identically.
*
* On success the form resets to the values it just saved which clears the
* dirty state and makes the Save button correctly go quiet and
* `router.refresh()` re-renders the server component above it, so the heading
* and the readiness panel reflect the edit without a full reload.
*/
export function ProjectDetailsForm({
details,
projectTypes,
}: ProjectDetailsFormProps) {
const router = useRouter();
const form = useForm<UpdateProjectFormValues>({
resolver: zodResolver(updateProjectSchema),
defaultValues: projectDetailsToFormValues(details),
});
const mutation = useMutation(
(values: UpdateProjectFormValues) => updateProject(details.id, values),
{
onSuccess: (_data, values) => {
form.reset(values);
router.refresh();
},
},
);
return (
<section data-testid="project-details">
<h2 className="text-lg font-semibold text-gray-900">Project details</h2>
<p className="text-sm text-gray-500 mt-1">
What this programme of works is called, who it is for, and when it runs.
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
className="mt-4 space-y-4 rounded-xl border border-gray-200 bg-white p-6"
data-testid="project-details-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g. Riverside retrofit 2026"
data-testid="project-details-name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Client</FormLabel>
<p
className="text-sm text-gray-700"
data-testid="project-details-organisation"
>
{details.organisationName ?? "—"}
</p>
<FormDescription>
A project cannot be moved to another organisation that would
change who can see it.
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="projectTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Project type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger data-testid="project-details-type">
<SelectValue placeholder="Select type..." />
</SelectTrigger>
</FormControl>
<SelectContent>
{projectTypes.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<textarea
{...field}
rows={3}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="What is this programme of works for?"
data-testid="project-details-description"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Start date</FormLabel>
<FormControl>
<DateInput
{...field}
data-testid="project-details-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>Estimated end date</FormLabel>
<FormControl>
<DateInput
{...field}
data-testid="project-details-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="domnaAdminAccess"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-testid="project-details-domna-admin-access"
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Grant Domna admin access</FormLabel>
<FormDescription>
Domna admins can help configure this project, support
imports, and view project data. This can be revoked at any
time.
</FormDescription>
</div>
</FormItem>
)}
/>
{mutation.isError && (
<p
role="alert"
className="text-sm text-red-600"
data-testid="project-details-error"
>
{(mutation.error as Error).message}
</p>
)}
{mutation.isSuccess && !form.formState.isDirty && (
<p
role="status"
className="text-sm text-emerald-700"
data-testid="project-details-saved"
>
Saved.
</p>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={mutation.isLoading || !form.formState.isDirty}
data-testid="project-details-submit"
>
{mutation.isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</Form>
</section>
);
}

View file

@ -0,0 +1,112 @@
import Link from "next/link";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import type { ImportReadiness } from "@/lib/projects/import/readiness";
import { readinessSummary } from "../setupSteps";
/**
* Human-readable phrase for each unmet requirement. Deliberately the same two
* phrases the import screen's "finish setup" state uses, so a user who follows
* its CTA here reads the identical words about the identical workstreams.
*/
const GAP_LABEL = {
stages: "no stages",
contractor: "no contractor assigned",
} as const;
/**
* The setup-status panel: where a project stands against the import gate.
*
* A plain server component over the `ImportReadiness` the page loaded from the
* shared helper (`@/lib/projects/import/readiness`). It states no rule of its
* own `readiness.ready` is the import gate's own verdict, read verbatim, so a
* project this panel calls ready is exactly one the import screen lets through.
*
* It is the first thing on the hub because #414's "finish setup first" CTA
* links here: following that link must land on the answer to "what is still
* missing?", not on a form.
*/
export function SetupStatusPanel({
readiness,
importHref,
}: {
readiness: ImportReadiness;
importHref: string;
}) {
const ready = readiness.ready;
return (
<section
data-testid="setup-status-panel"
data-ready={ready ? "true" : "false"}
className={`rounded-2xl border p-6 ${
ready
? "border-emerald-200 bg-emerald-50/60"
: "border-amber-200 bg-amber-50/70"
}`}
>
<div className="flex items-start">
{ready ? (
<CheckCircleIcon className="h-6 w-6 mr-3 shrink-0 text-emerald-600" />
) : (
<ExclamationTriangleIcon className="h-6 w-6 mr-3 shrink-0 text-amber-500" />
)}
<div className="min-w-0 flex-1">
<h2
className={`text-lg font-semibold ${
ready ? "text-emerald-900" : "text-amber-900"
}`}
>
{ready ? "Ready to import" : "Setup not finished"}
</h2>
<p
className={`mt-1 text-sm ${
ready ? "text-emerald-800" : "text-amber-800"
}`}
data-testid="setup-status-summary"
>
{readinessSummary(readiness)}
</p>
{readiness.hasWorkstreams && (
<ul className="mt-4 space-y-2" data-testid="setup-status-workstreams">
{readiness.workstreams.map((w) => (
<li
key={String(w.projectWorkstreamId)}
data-testid="setup-status-workstream"
data-complete={w.complete ? "true" : "false"}
className="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
>
<span className="font-medium text-gray-800 truncate">
{w.workstreamName}
</span>
{w.complete ? (
<span className="shrink-0 text-xs font-medium text-emerald-700">
stages · contractor
</span>
) : (
<span className="shrink-0 text-xs font-medium text-amber-700">
{w.missing.map((gap) => GAP_LABEL[gap]).join(" · ")}
</span>
)}
</li>
))}
</ul>
)}
{ready && (
<Link
href={importHref}
data-testid="setup-status-import-link"
className="mt-5 inline-flex items-center rounded-md bg-brandblue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brandblue/90"
>
Import work orders
</Link>
)}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,97 @@
import Link from "next/link";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import type { SetupStep, SetupStepState } from "../setupSteps";
/** How each state is badged. `optional` is neutral — it is never a warning. */
const STATE_BADGE: Record<SetupStepState, { label: string; className: string }> =
{
done: {
label: "Done",
className: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
todo: {
label: "Needs setup",
className: "bg-amber-50 text-amber-700 border-amber-200",
},
optional: {
label: "Optional",
className: "bg-gray-50 text-gray-500 border-gray-200",
},
};
/** "Windows, Doors and 2 more" — the workstreams a step is outstanding for. */
function outstandingLine(step: SetupStep): string | null {
if (step.outstanding.length === 0) return null;
const names = step.outstanding.map((w) => w.workstreamName);
const shown = names.slice(0, 3).join(", ");
const rest = names.length - 3;
return rest > 0 ? `Outstanding for ${shown} and ${rest} more` : `Outstanding for ${shown}`;
}
/**
* The four setup steps, as revisitable links rather than wizard stops.
*
* Setup is something a user comes back to (ADR-0019 made import setup-gated, so
* a project is configured over time), which is why every step is a plain link
* to an independently addressable route and none of them is disabled or locked
* behind the one before it. The numbering records the order of the first-run
* walk-through; it does not constrain the order things may be edited in.
*/
export function SetupStepsList({ steps }: { steps: SetupStep[] }) {
return (
<section data-testid="setup-steps">
<h2 className="text-lg font-semibold text-gray-900">Project setup</h2>
<p className="text-sm text-gray-500 mt-1">
Every step stays editable change any of it whenever you need to.
</p>
<ul className="mt-4 space-y-3">
{steps.map((step) => {
const badge = STATE_BADGE[step.state];
const outstanding = outstandingLine(step);
return (
<li key={step.key}>
<Link
href={step.href}
data-testid={`setup-step-${step.key}`}
data-state={step.state}
className="group flex items-start gap-4 rounded-xl border border-gray-200 bg-white p-4 transition hover:border-brandblue/40 hover:bg-gray-50/60"
>
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-100 text-xs font-semibold text-gray-600">
{step.position}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-900">
{step.title}
</span>
<span
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${badge.className}`}
>
{badge.label}
</span>
</span>
<span className="mt-1 block text-xs leading-relaxed text-gray-500">
{step.description}
</span>
{outstanding && (
<span
className="mt-1 block text-xs font-medium text-amber-700"
data-testid={`setup-step-${step.key}-outstanding`}
>
{outstanding}
</span>
)}
</span>
<ArrowRightIcon className="mt-1 h-4 w-4 shrink-0 text-gray-300 transition group-hover:text-brandblue" />
</Link>
</li>
);
})}
</ul>
</section>
);
}

View file

@ -0,0 +1,93 @@
import { notFound } from "next/navigation";
import { requireProjectAccess } from "../../guards";
import { canManageProject } from "@/lib/projects/authz";
import { loadImportReadiness } from "@/lib/projects/import/readiness";
import { loadProjectDetails } from "@/app/repositories/projects/projectSettingsRepository";
import { listProjectTypes } from "@/app/repositories/projects/projectsRepository";
import { buildSetupSteps } from "./setupSteps";
import { SetupStatusPanel } from "./components/SetupStatusPanel";
import { SetupStepsList } from "./components/SetupStepsList";
import { ProjectDetailsForm } from "./components/ProjectDetailsForm";
export const metadata = {
title: "Project settings | Ara",
};
/**
* Project settings / setup hub (issue #444).
*
* The target of two links that previously 404'd — the per-project nav's
* **Settings** item and the import screen's "finish setup first" CTA and the
* home of setup as a thing you *return to*. ADR-0019 made work-order import
* setup-gated, which turned setup from a one-shot wizard into something a
* project accretes over time: create it, add workstreams, configure stages and
* contractors as they are agreed, import once every workstream is ready.
*
* The page is three bands, in the order the CTA's sender needs them:
*
* 1. **Setup status** where the project stands against the import gate,
* straight off `loadImportReadiness`. The rule is not restated here; this
* page renders the same `ImportReadiness` value the import screen branches
* on, so "ready" cannot mean two different things in two places.
* 2. **Setup steps** the four `/setup/*` routes as revisitable links, each
* independently addressable, none locked behind the one before it.
* 3. **Project details** the editable fields, saved through
* `PATCH /api/projects/[projectId]`.
*
* Authorization is stricter than viewing: settings is a management surface, so
* a contractor with legitimate view access must not reach it at all. 404 rather
* than 403, matching the import page and the guards' own reasoning project
* existence stays private, and "not yours to manage" leaks nothing extra.
*/
export default async function ProjectSettingsPage(props: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await props.params;
// Resolves the user, parses the id segment and enforces *view* access —
// a junk id or a project outside the user's organisation 404s in the guard.
const { user, project } = await requireProjectAccess(projectId);
if (!canManageProject(user, project)) notFound();
// Read-only: the readiness verdict, the editable row, and the type options.
const [readiness, details, projectTypes] = await Promise.all([
loadImportReadiness(project.id),
loadProjectDetails(project.id),
listProjectTypes(),
]);
// The guard proved the project exists; a null here means it was deleted
// between the two reads, which is a 404 like any other missing project.
if (!details) notFound();
const steps = buildSetupSteps(projectId, readiness);
return (
<div className="max-w-4xl mx-auto px-6 py-10 space-y-10">
<header>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Ara Projects
</p>
<h1
className="text-3xl font-extrabold text-gray-900 tracking-tight"
data-testid="project-settings-heading"
>
{details.name}
</h1>
<p className="text-sm text-gray-500 mt-1">
Project settings and setup
{details.organisationName ? ` · ${details.organisationName}` : ""}
</p>
</header>
<SetupStatusPanel
readiness={readiness}
importHref={`/projects/${projectId}/import`}
/>
<SetupStepsList steps={steps} />
<ProjectDetailsForm details={details} projectTypes={projectTypes} />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more