mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
Merge pull request #464 from Hestia-Homes/main
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
woo deploy!
This commit is contained in:
commit
b896f22bf6
165 changed files with 93368 additions and 213 deletions
23
CLAUDE.md
23
CLAUDE.md
|
|
@ -10,6 +10,29 @@
|
|||
- **Avoid `useEffect` and `useMemo`.** Derive values inline, prefer Server Components + Route Handlers, prefer event handlers. If a hook is genuinely the only option, flag it and ask before using it.
|
||||
- **Use TanStack Query (`@tanstack/react-query`), not raw `fetch`, for client-side HTTP.** Reads use `useQuery`; writes use `useMutation`. This project is on **v4** — note that `refetchInterval`'s callback signature is `(data, query)`, not v5's `(query)`.
|
||||
|
||||
## Dates
|
||||
|
||||
- **`dd/mm/yyyy` everywhere a person reads or types a date.** This is a UK
|
||||
product. `03/09/2026` is 3 September; rendering it as 3 March is silently
|
||||
wrong data, not a cosmetic bug.
|
||||
- **Never `<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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
235
cypress/e2e/projects/create-project.cy.js
Normal file
235
cypress/e2e/projects/create-project.cy.js
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
102
docs/adr/0019-uk-date-format-in-form-inputs.md
Normal file
102
docs/adr/0019-uk-date-format-in-form-inputs.md
Normal 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: 0015–0017 are in flight on the reporting-redesign branch
|
||||
(PR #416), alongside 0010; 0019 follows ADR-0018 and avoids collision with all
|
||||
of them.
|
||||
|
||||
## Context
|
||||
|
||||
This is a UK product. Everything we *display* already knows that — some forty
|
||||
call sites pass `"en-GB"` to `toLocaleDateString` / `Intl.DateTimeFormat`, so a
|
||||
rendered date reads `3 September 2026` or `03/09/2026`.
|
||||
|
||||
Form **inputs** did not follow. Every date field in the app was
|
||||
`<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.
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# 19. Work-order import is the setup-gated final wizard step, and carries contractor per row
|
||||
|
||||
Date: 2026-07-22
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
Numbering note: 0015–0017 are in flight on the reporting-redesign branch, and
|
||||
0010 alongside them (see ADR-0018's note). 0019 follows 0018 and avoids all of
|
||||
them.
|
||||
|
||||
## Context
|
||||
|
||||
The Ara Projects "second import" (issue #414) matches a client-supplied
|
||||
spreadsheet to Properties already in the DB and declares which Workstreams each
|
||||
Property gets, as one row per (Property, Workstream). Committing those rows as
|
||||
Work orders is #417.
|
||||
|
||||
Two questions came up once the schema (PR #404) and the setup wizard shape
|
||||
(wireframes 02–07) were concrete:
|
||||
|
||||
1. **When is a project allowed to import?** A `work_order` row is
|
||||
`(property, project_workstream_stage, project_workstream_contractor,
|
||||
reference, forecast_end)` — all NOT NULL except `forecast_end`. So a Work
|
||||
order cannot exist for a workstream that has no stage ladder and no assigned
|
||||
contractor. Importing against a half-configured project would fail row by
|
||||
row at commit with nothing the user could act on up front.
|
||||
|
||||
2. **Where does each row's contractor come from?** The original #417 sketch
|
||||
derived the contractor from the workstream: each `project_workstream` was
|
||||
assumed to have a single contractor assignment, so the importer would look
|
||||
it up and every Work order for that workstream went to that one org. But
|
||||
`project_workstream_contractor` is a **collection** — a workstream can have
|
||||
more than one contractor assigned (CONTEXT.md, "Contractor"), and real
|
||||
programmes split one workstream's properties across several contractors by
|
||||
area or batch. A single-assignee lookup cannot express that.
|
||||
|
||||
## Decision
|
||||
|
||||
**Import is the final, setup-gated step of the setup wizard.**
|
||||
|
||||
- The wizard's earlier steps select workstreams, configure each workstream's
|
||||
stage ladder, and assign contractors. Import is the last step; #413 routes
|
||||
the wizard into this same `/projects/[projectId]/import` screen. It also
|
||||
stays reachable standalone from its own nav button.
|
||||
- The screen is **gated on setup completeness** regardless of entry point.
|
||||
Rule: every `project_workstream` on the project must have **≥1
|
||||
`project_workstream_stage` AND ≥1 `project_workstream_contractor`**, and the
|
||||
project must have at least one workstream. Until then the drop zone is
|
||||
replaced by a "finish setup" state naming the incomplete workstreams and
|
||||
linking back to configuration — not a 404 and not a silent empty zone.
|
||||
- Evidence requirements (#412) are advisory and are **not** part of the gate.
|
||||
- The gate is computed by `computeImportReadiness` (pure) /
|
||||
`loadImportReadiness` (one read-only query) in
|
||||
`src/lib/projects/import/readiness.ts`, returning per-workstream detail so
|
||||
both this page and #413 can render *what* is missing.
|
||||
|
||||
**Contractor is supplied per import row.**
|
||||
|
||||
- The template gains a required `Contractor` column. Each row names the
|
||||
contractor that delivers that (Property, Workstream), and it must match a
|
||||
contractor **already assigned** to that workstream via
|
||||
`project_workstream_contractor`. Validation (#417) rejects a contractor that
|
||||
is not an existing assignment.
|
||||
- **Contractor creation is out of scope for import.** Bringing a new company
|
||||
into Ara and assigning it to a workstream is owned by the pre-existing
|
||||
"invite company to Ara" flow; import only references assignments that flow
|
||||
has already made. This is why the gate requires a contractor per workstream
|
||||
before import opens — the assignments must exist first.
|
||||
|
||||
**Stage is optional per row.**
|
||||
|
||||
- The template gains an optional `Stage` column. A blank stage falls back at
|
||||
commit to the workstream's first stage (`project_workstream_stage` ordered by
|
||||
`order`, ascending — the ladder's start; see CONTEXT.md, "Stage"). A named
|
||||
stage must match one on that workstream's ladder.
|
||||
|
||||
The raw parser (`parse.ts`) stays header-agnostic: it does not enforce the
|
||||
required/optional distinction or that names resolve. Required-ness is the
|
||||
mapping step's job (#415) and value resolution is the commit step's (#417).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Trade-off vs the replaced #417 design.** Deriving the contractor from the
|
||||
workstream's single assignee was simpler — one fewer required column, and no
|
||||
per-row contractor to validate. We give that up because it silently assumed
|
||||
one contractor per workstream, which the schema does not guarantee and real
|
||||
programmes violate. Carrying the contractor per row costs the client a column
|
||||
they must fill and a validation failure mode ("contractor X isn't assigned to
|
||||
workstream Y"), but it is the only shape that supports a workstream split
|
||||
across contractors, and it keeps the assignment set — not the spreadsheet —
|
||||
as the source of truth for who may receive work.
|
||||
- The setup gate means a project cannot reach the drop zone mid-configuration.
|
||||
That is the intended safety property (no un-committable imports), but it does
|
||||
make import unreachable until contractors are assigned — acceptable because
|
||||
assignment is a prerequisite for issuing any work order at all.
|
||||
- `readiness.ts` has two consumers (this page and #413), so its interface is
|
||||
kept deliberately stable and detail-rich.
|
||||
- Not yet decided here: how a row's named contractor disambiguates when the
|
||||
same org is assigned to a workstream more than once, and the exact
|
||||
stage-name matching rule. Both are #417's to settle at commit; this ADR fixes
|
||||
only that the contractor is per-row-and-pre-assigned and the stage is
|
||||
optional-with-first-stage-fallback.
|
||||
91
docs/adr/0020-import-session-and-mapping-are-client-held.md
Normal file
91
docs/adr/0020-import-session-and-mapping-are-client-held.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
146
docs/adr/0021-stage-ladder-seeding-and-the-status-column.md
Normal file
146
docs/adr/0021-stage-ladder-seeding-and-the-status-column.md
Normal 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
|
||||
|
|
@ -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).
|
||||
|
|
@ -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
60
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
254
src/app/api/projects/[projectId]/contractors/assignments.test.ts
Normal file
254
src/app/api/projects/[projectId]/contractors/assignments.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
178
src/app/api/projects/[projectId]/contractors/assignments.ts
Normal file
178
src/app/api/projects/[projectId]/contractors/assignments.ts
Normal 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" };
|
||||
}
|
||||
16
src/app/api/projects/[projectId]/contractors/authorize.ts
Normal file
16
src/app/api/projects/[projectId]/contractors/authorize.ts
Normal 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";
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
148
src/app/api/projects/[projectId]/contractors/permissions.test.ts
Normal file
148
src/app/api/projects/[projectId]/contractors/permissions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
412
src/app/api/projects/[projectId]/contractors/queries.ts
Normal file
412
src/app/api/projects/[projectId]/contractors/queries.ts
Normal 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;
|
||||
}
|
||||
355
src/app/api/projects/[projectId]/contractors/route.test.ts
Normal file
355
src/app/api/projects/[projectId]/contractors/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
106
src/app/api/projects/[projectId]/contractors/route.ts
Normal file
106
src/app/api/projects/[projectId]/contractors/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
52
src/app/api/projects/[projectId]/contractors/validation.ts
Normal file
52
src/app/api/projects/[projectId]/contractors/validation.ts
Normal 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>;
|
||||
99
src/app/api/projects/[projectId]/import/parse/route.ts
Normal file
99
src/app/api/projects/[projectId]/import/parse/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
56
src/app/api/projects/[projectId]/import/template/route.ts
Normal file
56
src/app/api/projects/[projectId]/import/template/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
99
src/app/api/projects/[projectId]/route.ts
Normal file
99
src/app/api/projects/[projectId]/route.ts
Normal 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 });
|
||||
}
|
||||
49
src/app/api/projects/[projectId]/sharepoint/queries.ts
Normal file
49
src/app/api/projects/[projectId]/sharepoint/queries.ts
Normal 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));
|
||||
}
|
||||
162
src/app/api/projects/[projectId]/sharepoint/route.test.ts
Normal file
162
src/app/api/projects/[projectId]/sharepoint/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
71
src/app/api/projects/[projectId]/sharepoint/route.ts
Normal file
71
src/app/api/projects/[projectId]/sharepoint/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
63
src/app/api/projects/[projectId]/work-orders/authorize.ts
Normal file
63
src/app/api/projects/[projectId]/work-orders/authorize.ts
Normal 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 };
|
||||
}
|
||||
194
src/app/api/projects/[projectId]/work-orders/filters.test.ts
Normal file
194
src/app/api/projects/[projectId]/work-orders/filters.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
243
src/app/api/projects/[projectId]/work-orders/filters.ts
Normal file
243
src/app/api/projects/[projectId]/work-orders/filters.ts
Normal 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;
|
||||
}
|
||||
216
src/app/api/projects/[projectId]/work-orders/queries.test.ts
Normal file
216
src/app/api/projects/[projectId]/work-orders/queries.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
416
src/app/api/projects/[projectId]/work-orders/queries.ts
Normal file
416
src/app/api/projects/[projectId]/work-orders/queries.ts
Normal 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();
|
||||
}
|
||||
194
src/app/api/projects/[projectId]/work-orders/route.test.ts
Normal file
194
src/app/api/projects/[projectId]/work-orders/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
44
src/app/api/projects/[projectId]/work-orders/route.ts
Normal file
44
src/app/api/projects/[projectId]/work-orders/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
176
src/app/api/projects/[projectId]/work-orders/rows.test.ts
Normal file
176
src/app/api/projects/[projectId]/work-orders/rows.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
144
src/app/api/projects/[projectId]/work-orders/rows.ts
Normal file
144
src/app/api/projects/[projectId]/work-orders/rows.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* DELETE `/api/projects/[projectId]/workstreams/[workstreamId]` (issue #410) —
|
||||
* the deselect-with-dependents path.
|
||||
*
|
||||
* The database is mocked at the module boundary; no connection is opened. The
|
||||
* cascade rules under test are the real ones from `../cascade`.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { NO_DEPENDENTS, type WorkstreamDependents } from "../cascade";
|
||||
|
||||
const {
|
||||
mockGetServerSession,
|
||||
mockLoadAuthzUser,
|
||||
mockLoadProjectAuthzFacts,
|
||||
mockFindSelection,
|
||||
mockDeselectWorkstream,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetServerSession: vi.fn(),
|
||||
mockLoadAuthzUser: vi.fn(),
|
||||
mockLoadProjectAuthzFacts: vi.fn(),
|
||||
mockFindSelection: vi.fn(),
|
||||
mockDeselectWorkstream: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
|
||||
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
|
||||
vi.mock("@/app/repositories/projects/authzRepository", () => ({
|
||||
loadAuthzUser: mockLoadAuthzUser,
|
||||
loadProjectAuthzFacts: mockLoadProjectAuthzFacts,
|
||||
}));
|
||||
vi.mock("../queries", () => ({
|
||||
findSelection: mockFindSelection,
|
||||
deselectWorkstream: mockDeselectWorkstream,
|
||||
}));
|
||||
|
||||
import { DELETE } from "./route";
|
||||
|
||||
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
|
||||
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
function signedInAs(organisationIds: string[]) {
|
||||
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
|
||||
mockLoadAuthzUser.mockResolvedValue({
|
||||
id: 7n,
|
||||
email: "someone@landlord.example",
|
||||
organisationIds,
|
||||
});
|
||||
mockLoadProjectAuthzFacts.mockResolvedValue({
|
||||
id: 5n,
|
||||
organisationId: CLIENT_ORG,
|
||||
domnaAdminAccess: false,
|
||||
contractorOrganisationIds: [CONTRACTOR_ORG],
|
||||
});
|
||||
}
|
||||
|
||||
function selectionWith(dependents: Partial<WorkstreamDependents>) {
|
||||
mockFindSelection.mockResolvedValue({
|
||||
id: 9n,
|
||||
dependents: { ...NO_DEPENDENTS, ...dependents },
|
||||
});
|
||||
}
|
||||
|
||||
function request({
|
||||
confirm = false,
|
||||
projectId = "5",
|
||||
workstreamId = "1",
|
||||
}: { confirm?: boolean; projectId?: string; workstreamId?: string } = {}) {
|
||||
const url = `http://localhost/api/projects/${projectId}/workstreams/${workstreamId}${
|
||||
confirm ? "?confirm=true" : ""
|
||||
}`;
|
||||
return {
|
||||
req: new NextRequest(url, { method: "DELETE" }),
|
||||
props: { params: Promise.resolve({ projectId, workstreamId }) },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDeselectWorkstream.mockResolvedValue(undefined);
|
||||
selectionWith({});
|
||||
});
|
||||
|
||||
describe("DELETE", () => {
|
||||
it("401s without a session", async () => {
|
||||
mockGetServerSession.mockResolvedValue(null);
|
||||
const { req, props } = request();
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("403s for a contractor", async () => {
|
||||
signedInAs([CONTRACTOR_ORG]);
|
||||
const { req, props } = request();
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("400s on a non-numeric workstream id", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
const { req, props } = request({ workstreamId: "windows" });
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: "Invalid workstream" });
|
||||
});
|
||||
|
||||
it("404s when the workstream is not selected on the project", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
mockFindSelection.mockResolvedValue(null);
|
||||
const { req, props } = request();
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deselects a workstream nothing hangs off", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
const { req, props } = request();
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ workstreamId: "1", selected: false });
|
||||
expect(mockDeselectWorkstream).toHaveBeenCalledWith(9n);
|
||||
});
|
||||
|
||||
it("409s asking for confirmation when configuration exists", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
selectionWith({ stages: 5, contractors: 1 });
|
||||
const { req, props } = request();
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({
|
||||
requiresConfirmation: true,
|
||||
dependents: { stages: 5, contractors: 1 },
|
||||
});
|
||||
expect(body.error).toContain("5 stages and 1 contractor assignment");
|
||||
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deselects once the caller confirms", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
selectionWith({ stages: 5, evidenceRequirements: 2, contractors: 1 });
|
||||
const { req, props } = request({ confirm: true });
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockDeselectWorkstream).toHaveBeenCalledWith(9n);
|
||||
});
|
||||
|
||||
it("409s blocked when work orders exist", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
selectionWith({ stages: 5, workOrders: 3 });
|
||||
const { req, props } = request();
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ blocked: true, dependents: { workOrders: 3 } });
|
||||
expect(body.requiresConfirmation).toBeUndefined();
|
||||
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays blocked when the caller confirms anyway", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
selectionWith({ workOrders: 1 });
|
||||
const { req, props } = request({ confirm: true });
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toMatchObject({ blocked: true });
|
||||
expect(mockDeselectWorkstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("500s without leaking the driver error", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockDeselectWorkstream.mockRejectedValue(new Error("deadlock detected"));
|
||||
const { req, props } = request();
|
||||
const res = await DELETE(req, props);
|
||||
expect(res.status).toBe(500);
|
||||
expect(await res.json()).toEqual({
|
||||
error: "Couldn't remove the workstream",
|
||||
});
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* `DELETE /api/projects/[projectId]/workstreams/[workstreamId]` (issue #410).
|
||||
*
|
||||
* Deselect a workstream. Keyed on `workstream.id` rather than the
|
||||
* `project_workstream.id` so the card grid can call it with the id it already
|
||||
* renders, and so a repeat call is a clean 404 rather than a dangling handle.
|
||||
*
|
||||
* The cascade rules (`../cascade`) decide the outcome:
|
||||
* - work orders exist → 409, `blocked: true`, never overridable
|
||||
* - configuration exists → 409, `requiresConfirmation: true`, until
|
||||
* the caller repeats the request with
|
||||
* `?confirm=true`
|
||||
* - otherwise → deleted
|
||||
*
|
||||
* The 409 bodies keep the `{ error: string }` shape and add the flags and
|
||||
* counts the UI needs to raise the right dialog.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeWorkstreamRequest } from "../authorize";
|
||||
import { decideDeselect } from "../cascade";
|
||||
import { deselectWorkstream, findSelection } from "../queries";
|
||||
|
||||
type Params = { params: Promise<{ projectId: string; workstreamId: string }> };
|
||||
|
||||
export async function DELETE(req: NextRequest, props: Params) {
|
||||
const { projectId, workstreamId } = await props.params;
|
||||
const auth = await authorizeWorkstreamRequest(projectId, "manage");
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(workstreamId)) {
|
||||
return NextResponse.json({ error: "Invalid workstream" }, { status: 400 });
|
||||
}
|
||||
|
||||
const selection = await findSelection(auth.projectId, BigInt(workstreamId));
|
||||
if (!selection) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workstream is not selected on this project" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const confirmed = req.nextUrl.searchParams.get("confirm") === "true";
|
||||
const decision = decideDeselect(selection.dependents, confirmed);
|
||||
|
||||
if (decision.kind === "blocked") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: decision.reason,
|
||||
blocked: true,
|
||||
dependents: selection.dependents,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (decision.kind === "needs-confirmation") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: decision.reason,
|
||||
requiresConfirmation: true,
|
||||
dependents: selection.dependents,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await deselectWorkstream(selection.id);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"DELETE /api/projects/[projectId]/workstreams/[workstreamId] failed:",
|
||||
err,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Couldn't remove the workstream" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ workstreamId, selected: false });
|
||||
}
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
@ -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" };
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/app/api/projects/[projectId]/workstreams/authorize.ts
Normal file
71
src/app/api/projects/[projectId]/workstreams/authorize.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Request authorization for the workstream-selection endpoints (issue #410).
|
||||
*
|
||||
* Thin wiring only: the session comes from NextAuth, the facts from the
|
||||
* projects authz repository, and every *decision* from `@/lib/projects/authz`
|
||||
* (#408). Nothing here re-implements a permission rule.
|
||||
*
|
||||
* A project the caller cannot see is reported as 404, never 403, so project
|
||||
* existence is not leaked outside its organisation — matching the guard in
|
||||
* `src/app/projects/authz.ts`.
|
||||
*/
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import {
|
||||
loadAuthzUser,
|
||||
loadProjectAuthzFacts,
|
||||
} from "@/app/repositories/projects/authzRepository";
|
||||
import { canManageProject, canViewProject } from "@/lib/projects/authz";
|
||||
|
||||
export type AuthorizeFailure = { error: string; status: 400 | 401 | 403 | 404 };
|
||||
|
||||
export type AuthorizeSuccess = {
|
||||
projectId: bigint;
|
||||
/** Whether the caller may edit the selection, not merely read it. */
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
export type AuthorizeResult =
|
||||
| ({ ok: true } & AuthorizeSuccess)
|
||||
| ({ ok: false } & AuthorizeFailure);
|
||||
|
||||
/**
|
||||
* Resolve the caller's standing on `projectIdParam`.
|
||||
*
|
||||
* `capability` is what the request needs: `"view"` for a read, `"manage"` for
|
||||
* a write. The returned `canManage` lets a read tell the client whether to
|
||||
* render the grid editable.
|
||||
*/
|
||||
export async function authorizeWorkstreamRequest(
|
||||
projectIdParam: string,
|
||||
capability: "view" | "manage",
|
||||
): Promise<AuthorizeResult> {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.dbId) {
|
||||
return { ok: false, error: "Unauthorised", status: 401 };
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(projectIdParam)) {
|
||||
return { ok: false, error: "Invalid project", status: 400 };
|
||||
}
|
||||
const projectId = BigInt(projectIdParam);
|
||||
|
||||
const [user, project] = await Promise.all([
|
||||
loadAuthzUser(BigInt(session.user.dbId)),
|
||||
loadProjectAuthzFacts(projectId),
|
||||
]);
|
||||
|
||||
if (!user) return { ok: false, error: "Unauthorised", status: 401 };
|
||||
if (!project) return { ok: false, error: "Project not found", status: 404 };
|
||||
|
||||
if (!canViewProject(user, project)) {
|
||||
return { ok: false, error: "Project not found", status: 404 };
|
||||
}
|
||||
|
||||
const canManage = canManageProject(user, project);
|
||||
if (capability === "manage" && !canManage) {
|
||||
return { ok: false, error: "Forbidden", status: 403 };
|
||||
}
|
||||
|
||||
return { ok: true, projectId, canManage };
|
||||
}
|
||||
124
src/app/api/projects/[projectId]/workstreams/cascade.test.ts
Normal file
124
src/app/api/projects/[projectId]/workstreams/cascade.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* The deselect cascade rules (issue #410). Pure logic — no mocks needed.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
NO_DEPENDENTS,
|
||||
cascadingCount,
|
||||
decideDeselect,
|
||||
describeDependents,
|
||||
type WorkstreamDependents,
|
||||
} from "./cascade";
|
||||
|
||||
function dependents(
|
||||
overrides: Partial<WorkstreamDependents> = {},
|
||||
): WorkstreamDependents {
|
||||
return { ...NO_DEPENDENTS, ...overrides };
|
||||
}
|
||||
|
||||
describe("decideDeselect", () => {
|
||||
it("allows deselecting a workstream nothing hangs off", () => {
|
||||
expect(decideDeselect(dependents(), false)).toEqual({ kind: "allowed" });
|
||||
});
|
||||
|
||||
it("asks for confirmation when stages are configured", () => {
|
||||
const decision = decideDeselect(dependents({ stages: 5 }), false);
|
||||
expect(decision.kind).toBe("needs-confirmation");
|
||||
expect(decision).toHaveProperty("reason", expect.stringContaining("5 stages"));
|
||||
});
|
||||
|
||||
it("asks for confirmation when evidence requirements exist", () => {
|
||||
expect(
|
||||
decideDeselect(dependents({ evidenceRequirements: 1 }), false).kind,
|
||||
).toBe("needs-confirmation");
|
||||
});
|
||||
|
||||
it("asks for confirmation when a contractor is assigned", () => {
|
||||
expect(decideDeselect(dependents({ contractors: 1 }), false).kind).toBe(
|
||||
"needs-confirmation",
|
||||
);
|
||||
});
|
||||
|
||||
it("proceeds once the user has confirmed", () => {
|
||||
expect(
|
||||
decideDeselect(
|
||||
dependents({ stages: 5, evidenceRequirements: 2, contractors: 1 }),
|
||||
true,
|
||||
),
|
||||
).toEqual({ kind: "allowed" });
|
||||
});
|
||||
|
||||
it("blocks when work orders exist", () => {
|
||||
const decision = decideDeselect(dependents({ workOrders: 3 }), false);
|
||||
expect(decision.kind).toBe("blocked");
|
||||
expect(decision).toHaveProperty(
|
||||
"reason",
|
||||
expect.stringContaining("3 work orders"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps blocking work orders even when the user confirms", () => {
|
||||
// Confirmation acknowledges a warning; it is not an override.
|
||||
expect(decideDeselect(dependents({ workOrders: 1 }), true).kind).toBe(
|
||||
"blocked",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the work-order block ahead of the configuration warning", () => {
|
||||
const decision = decideDeselect(
|
||||
dependents({ stages: 4, workOrders: 2 }),
|
||||
false,
|
||||
);
|
||||
expect(decision.kind).toBe("blocked");
|
||||
});
|
||||
|
||||
it("singularises a lone work order", () => {
|
||||
expect(decideDeselect(dependents({ workOrders: 1 }), false)).toHaveProperty(
|
||||
"reason",
|
||||
expect.stringContaining("1 work order "),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeDependents", () => {
|
||||
it("is empty when nothing is configured", () => {
|
||||
expect(describeDependents(dependents())).toBe("");
|
||||
});
|
||||
|
||||
it("singularises a single dependent", () => {
|
||||
expect(describeDependents(dependents({ stages: 1 }))).toBe("1 stage");
|
||||
});
|
||||
|
||||
it("joins the last pair with 'and'", () => {
|
||||
expect(
|
||||
describeDependents(
|
||||
dependents({ stages: 5, evidenceRequirements: 2, contractors: 1 }),
|
||||
),
|
||||
).toBe("5 stages, 2 evidence requirements and 1 contractor assignment");
|
||||
});
|
||||
|
||||
it("omits the categories that are empty", () => {
|
||||
expect(describeDependents(dependents({ stages: 3, contractors: 2 }))).toBe(
|
||||
"3 stages and 2 contractor assignments",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores work orders, which are never deleted by a cascade", () => {
|
||||
expect(describeDependents(dependents({ workOrders: 9 }))).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascadingCount", () => {
|
||||
it("counts only the rows a confirmed deselect would delete", () => {
|
||||
expect(
|
||||
cascadingCount(
|
||||
dependents({
|
||||
stages: 5,
|
||||
evidenceRequirements: 2,
|
||||
contractors: 1,
|
||||
workOrders: 7,
|
||||
}),
|
||||
),
|
||||
).toBe(8);
|
||||
});
|
||||
});
|
||||
105
src/app/api/projects/[projectId]/workstreams/cascade.ts
Normal file
105
src/app/api/projects/[projectId]/workstreams/cascade.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Deselect cascade rules for a Project Workstream (issue #410).
|
||||
*
|
||||
* Deliberately **pure** — it imports no database client and no React — so the
|
||||
* route handler, the unit tests and the browser bundle can all share one copy
|
||||
* of the rules. The counts themselves are loaded in `./queries`.
|
||||
*
|
||||
* Two rules, in priority order:
|
||||
* 1. **Work orders block.** Work has been issued against the workstream; the
|
||||
* selection can no longer be withdrawn from the setup screen.
|
||||
* 2. **Configuration warns.** Stages, evidence requirements and contractor
|
||||
* assignments are the workstream's own configuration — deselecting throws
|
||||
* them away, so the user must confirm first.
|
||||
*
|
||||
* Everything else deselects silently.
|
||||
*/
|
||||
|
||||
/** What already hangs off one Project Workstream. */
|
||||
export interface WorkstreamDependents {
|
||||
stages: number;
|
||||
evidenceRequirements: number;
|
||||
contractors: number;
|
||||
workOrders: number;
|
||||
}
|
||||
|
||||
/** An unselected workstream, or a selected one nothing hangs off yet. */
|
||||
export const NO_DEPENDENTS: WorkstreamDependents = {
|
||||
stages: 0,
|
||||
evidenceRequirements: 0,
|
||||
contractors: 0,
|
||||
workOrders: 0,
|
||||
};
|
||||
|
||||
export type DeselectDecision =
|
||||
| { kind: "blocked"; reason: string }
|
||||
| { kind: "needs-confirmation"; reason: string }
|
||||
| { kind: "allowed" };
|
||||
|
||||
/** The dependents that are destroyed by a confirmed deselect. */
|
||||
const CASCADING: Array<{
|
||||
key: keyof WorkstreamDependents;
|
||||
one: string;
|
||||
many: string;
|
||||
}> = [
|
||||
{ key: "stages", one: "stage", many: "stages" },
|
||||
{
|
||||
key: "evidenceRequirements",
|
||||
one: "evidence requirement",
|
||||
many: "evidence requirements",
|
||||
},
|
||||
{
|
||||
key: "contractors",
|
||||
one: "contractor assignment",
|
||||
many: "contractor assignments",
|
||||
},
|
||||
];
|
||||
|
||||
/** How many rows a confirmed deselect would delete. */
|
||||
export function cascadingCount(dependents: WorkstreamDependents): number {
|
||||
return CASCADING.reduce((total, { key }) => total + dependents[key], 0);
|
||||
}
|
||||
|
||||
/** `"5 stages, 1 evidence requirement and 2 contractor assignments"`. */
|
||||
export function describeDependents(dependents: WorkstreamDependents): string {
|
||||
const parts = CASCADING.filter(({ key }) => dependents[key] > 0).map(
|
||||
({ key, one, many }) =>
|
||||
`${dependents[key]} ${dependents[key] === 1 ? one : many}`,
|
||||
);
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.length === 1) return parts[0];
|
||||
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a deselect may proceed.
|
||||
*
|
||||
* `confirmed` is the user having acknowledged the warning (the `?confirm=true`
|
||||
* query parameter on DELETE). It never unblocks a work-order block — that is a
|
||||
* hard rule, not a warning.
|
||||
*/
|
||||
export function decideDeselect(
|
||||
dependents: WorkstreamDependents,
|
||||
confirmed: boolean,
|
||||
): DeselectDecision {
|
||||
if (dependents.workOrders > 0) {
|
||||
const plural = dependents.workOrders === 1 ? "work order" : "work orders";
|
||||
return {
|
||||
kind: "blocked",
|
||||
reason:
|
||||
`This workstream has ${dependents.workOrders} ${plural} against it. ` +
|
||||
`Work orders must be removed before the workstream can be taken off the project.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!confirmed && cascadingCount(dependents) > 0) {
|
||||
return {
|
||||
kind: "needs-confirmation",
|
||||
reason:
|
||||
`Removing this workstream also deletes ${describeDependents(dependents)}. ` +
|
||||
`This can't be undone.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "allowed" };
|
||||
}
|
||||
248
src/app/api/projects/[projectId]/workstreams/queries.ts
Normal file
248
src/app/api/projects/[projectId]/workstreams/queries.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* Persistence for the workstream-selection setup screen (issue #410).
|
||||
*
|
||||
* The route handlers and the server-rendered page both read through here, so
|
||||
* the two always agree on shape — the page renders the first paint and the
|
||||
* handlers serve every refetch afterwards.
|
||||
*
|
||||
* The workstream catalogue is reference data seeded by #407 (migration 0274);
|
||||
* it is always read from the `workstream` table, never hard-coded.
|
||||
*/
|
||||
import { db } from "@/app/db/db";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
projectWorkstream,
|
||||
projectWorkstreamContractor,
|
||||
projectWorkstreamEvidenceRequirement,
|
||||
projectWorkstreamStage,
|
||||
workOrder,
|
||||
workstream,
|
||||
} from "@/app/db/schema/projects/projects";
|
||||
import { NO_DEPENDENTS, type WorkstreamDependents } from "./cascade";
|
||||
|
||||
/** One card in the grid: a catalogue row plus this project's standing on it. */
|
||||
export interface WorkstreamOption {
|
||||
/** `workstream.id`, serialised — the id the POST/DELETE endpoints take. */
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
/** `project_workstream.id` when selected, else null. */
|
||||
projectWorkstreamId: string | null;
|
||||
/** All zero when unselected. Drives the badges and the deselect warnings. */
|
||||
dependents: WorkstreamDependents;
|
||||
}
|
||||
|
||||
interface DependentRow extends WorkstreamDependents {
|
||||
id: bigint;
|
||||
workstreamId: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count what hangs off each Project Workstream matching `where`.
|
||||
*
|
||||
* One round trip: the four child tables are left-joined and counted with
|
||||
* `count(distinct)`, so the join fan-out between them does not inflate the
|
||||
* numbers. A Work order reaches its Project Workstream by two independent FKs
|
||||
* (its stage and its contractor assignment) — both are followed, so a row
|
||||
* hanging off either path still blocks the deselect.
|
||||
*/
|
||||
async function loadDependentRows(
|
||||
where: ReturnType<typeof and>,
|
||||
): Promise<DependentRow[]> {
|
||||
return db
|
||||
.select({
|
||||
id: projectWorkstream.id,
|
||||
workstreamId: projectWorkstream.workstreamId,
|
||||
stages: sql<number>`count(distinct ${projectWorkstreamStage.id})::int`,
|
||||
evidenceRequirements: sql<number>`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`,
|
||||
contractors: sql<number>`count(distinct ${projectWorkstreamContractor.id})::int`,
|
||||
workOrders: sql<number>`count(distinct ${workOrder.id})::int`,
|
||||
})
|
||||
.from(projectWorkstream)
|
||||
.leftJoin(
|
||||
projectWorkstreamStage,
|
||||
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id),
|
||||
)
|
||||
.leftJoin(
|
||||
projectWorkstreamEvidenceRequirement,
|
||||
eq(
|
||||
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
|
||||
projectWorkstream.id,
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
projectWorkstreamContractor,
|
||||
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
|
||||
)
|
||||
.leftJoin(
|
||||
workOrder,
|
||||
or(
|
||||
eq(workOrder.projectWorkstreamStageId, projectWorkstreamStage.id),
|
||||
eq(
|
||||
workOrder.projectWorkstreamContractorId,
|
||||
projectWorkstreamContractor.id,
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(where)
|
||||
.groupBy(projectWorkstream.id, projectWorkstream.workstreamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole card grid for one project: every seeded workstream, marked with
|
||||
* whether this project has selected it and what is configured underneath.
|
||||
*/
|
||||
export async function listWorkstreamOptions(
|
||||
projectId: bigint,
|
||||
): Promise<WorkstreamOption[]> {
|
||||
const [catalogue, selections] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: workstream.id,
|
||||
name: workstream.name,
|
||||
description: workstream.description,
|
||||
})
|
||||
.from(workstream)
|
||||
.orderBy(workstream.id),
|
||||
loadDependentRows(eq(projectWorkstream.projectId, projectId)),
|
||||
]);
|
||||
|
||||
const byWorkstreamId = new Map(
|
||||
selections.map((row) => [row.workstreamId.toString(), row]),
|
||||
);
|
||||
|
||||
return catalogue.map((row) => {
|
||||
const selection = byWorkstreamId.get(row.id.toString());
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
selected: Boolean(selection),
|
||||
projectWorkstreamId: selection ? selection.id.toString() : null,
|
||||
dependents: selection
|
||||
? {
|
||||
stages: selection.stages,
|
||||
evidenceRequirements: selection.evidenceRequirements,
|
||||
contractors: selection.contractors,
|
||||
workOrders: selection.workOrders,
|
||||
}
|
||||
: { ...NO_DEPENDENTS },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** True when `workstreamId` is a real catalogue row. */
|
||||
export async function workstreamExists(workstreamId: bigint): Promise<boolean> {
|
||||
const [found] = await db
|
||||
.select({ id: workstream.id })
|
||||
.from(workstream)
|
||||
.where(eq(workstream.id, workstreamId))
|
||||
.limit(1);
|
||||
return Boolean(found);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `project_workstream` row for one (project, workstream) pair, with its
|
||||
* dependent counts. Null when the workstream is not selected on the project.
|
||||
*/
|
||||
export async function findSelection(
|
||||
projectId: bigint,
|
||||
workstreamId: bigint,
|
||||
): Promise<{ id: bigint; dependents: WorkstreamDependents } | null> {
|
||||
const [row] = await loadDependentRows(
|
||||
and(
|
||||
eq(projectWorkstream.projectId, projectId),
|
||||
eq(projectWorkstream.workstreamId, workstreamId),
|
||||
),
|
||||
);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
dependents: {
|
||||
stages: row.stages,
|
||||
evidenceRequirements: row.evidenceRequirements,
|
||||
contractors: row.contractors,
|
||||
workOrders: row.workOrders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a workstream for a project, returning the `project_workstream` id.
|
||||
*
|
||||
* Idempotent: re-selecting an already-selected workstream returns the existing
|
||||
* row rather than a duplicate. `project_workstream` carries no unique index on
|
||||
* (project_id, workstream_id), so the check-then-insert is done inside a
|
||||
* transaction — the narrowest guard available without a migration.
|
||||
*/
|
||||
export async function selectWorkstream(
|
||||
projectId: bigint,
|
||||
workstreamId: bigint,
|
||||
): Promise<{ id: bigint; created: boolean }> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: projectWorkstream.id })
|
||||
.from(projectWorkstream)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkstream.projectId, projectId),
|
||||
eq(projectWorkstream.workstreamId, workstreamId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) return { id: existing.id, created: false };
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(projectWorkstream)
|
||||
.values({ projectId, workstreamId })
|
||||
.returning({ id: projectWorkstream.id });
|
||||
|
||||
return { id: inserted.id, created: true };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect a workstream, deleting the configuration that hangs off it.
|
||||
*
|
||||
* Callers must have run `decideDeselect` first: this deletes stages, evidence
|
||||
* requirements and contractor assignments unconditionally, and would fail on
|
||||
* the work-order FK if any work had been issued.
|
||||
*
|
||||
* `current_stage_id` is nulled before the stages go, because the Project
|
||||
* Workstream points back at one of its own stages.
|
||||
*/
|
||||
export async function deselectWorkstream(
|
||||
projectWorkstreamId: bigint,
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(projectWorkstream)
|
||||
.set({ currentStageId: null })
|
||||
.where(eq(projectWorkstream.id, projectWorkstreamId));
|
||||
|
||||
await tx
|
||||
.delete(projectWorkstreamEvidenceRequirement)
|
||||
.where(
|
||||
eq(
|
||||
projectWorkstreamEvidenceRequirement.projectWorkstreamId,
|
||||
projectWorkstreamId,
|
||||
),
|
||||
);
|
||||
|
||||
await tx
|
||||
.delete(projectWorkstreamContractor)
|
||||
.where(
|
||||
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstreamId),
|
||||
);
|
||||
|
||||
await tx
|
||||
.delete(projectWorkstreamStage)
|
||||
.where(eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId));
|
||||
|
||||
await tx
|
||||
.delete(projectWorkstream)
|
||||
.where(eq(projectWorkstream.id, projectWorkstreamId));
|
||||
});
|
||||
}
|
||||
228
src/app/api/projects/[projectId]/workstreams/route.test.ts
Normal file
228
src/app/api/projects/[projectId]/workstreams/route.test.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* GET / POST `/api/projects/[projectId]/workstreams` (issue #410).
|
||||
*
|
||||
* The database is mocked at the module boundary (`./queries` and the authz
|
||||
* repository); no connection is opened. The authorization *decisions* are the
|
||||
* real ones from `@/lib/projects/authz` — only the facts they read are faked.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const {
|
||||
mockGetServerSession,
|
||||
mockLoadAuthzUser,
|
||||
mockLoadProjectAuthzFacts,
|
||||
mockListWorkstreamOptions,
|
||||
mockSelectWorkstream,
|
||||
mockWorkstreamExists,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetServerSession: vi.fn(),
|
||||
mockLoadAuthzUser: vi.fn(),
|
||||
mockLoadProjectAuthzFacts: vi.fn(),
|
||||
mockListWorkstreamOptions: vi.fn(),
|
||||
mockSelectWorkstream: vi.fn(),
|
||||
mockWorkstreamExists: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
|
||||
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
|
||||
vi.mock("@/app/repositories/projects/authzRepository", () => ({
|
||||
loadAuthzUser: mockLoadAuthzUser,
|
||||
loadProjectAuthzFacts: mockLoadProjectAuthzFacts,
|
||||
}));
|
||||
vi.mock("./queries", () => ({
|
||||
listWorkstreamOptions: mockListWorkstreamOptions,
|
||||
selectWorkstream: mockSelectWorkstream,
|
||||
workstreamExists: mockWorkstreamExists,
|
||||
}));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
|
||||
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
|
||||
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
|
||||
const OTHER_ORG = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
const CATALOGUE = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Windows",
|
||||
description: "Replacement and repair of windows.",
|
||||
selected: true,
|
||||
projectWorkstreamId: "9",
|
||||
dependents: {
|
||||
stages: 5,
|
||||
evidenceRequirements: 0,
|
||||
contractors: 0,
|
||||
workOrders: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Doors",
|
||||
description: "Replacement and repair of doors.",
|
||||
selected: false,
|
||||
projectWorkstreamId: null,
|
||||
dependents: {
|
||||
stages: 0,
|
||||
evidenceRequirements: 0,
|
||||
contractors: 0,
|
||||
workOrders: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function signedInAs(organisationIds: string[]) {
|
||||
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
|
||||
mockLoadAuthzUser.mockResolvedValue({
|
||||
id: 7n,
|
||||
email: "someone@landlord.example",
|
||||
organisationIds,
|
||||
});
|
||||
}
|
||||
|
||||
function projectExists() {
|
||||
mockLoadProjectAuthzFacts.mockResolvedValue({
|
||||
id: 5n,
|
||||
organisationId: CLIENT_ORG,
|
||||
domnaAdminAccess: false,
|
||||
contractorOrganisationIds: [CONTRACTOR_ORG],
|
||||
});
|
||||
}
|
||||
|
||||
const params = (projectId = "5") => ({ params: Promise.resolve({ projectId }) });
|
||||
|
||||
function getRequest(projectId = "5") {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/projects/${projectId}/workstreams`,
|
||||
);
|
||||
}
|
||||
|
||||
function postRequest(body: unknown, projectId = "5") {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/projects/${projectId}/workstreams`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListWorkstreamOptions.mockResolvedValue(CATALOGUE);
|
||||
mockWorkstreamExists.mockResolvedValue(true);
|
||||
mockSelectWorkstream.mockResolvedValue({ id: 42n, created: true });
|
||||
});
|
||||
|
||||
describe("GET", () => {
|
||||
it("401s without a session", async () => {
|
||||
mockGetServerSession.mockResolvedValue(null);
|
||||
const res = await GET(getRequest(), params());
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: "Unauthorised" });
|
||||
});
|
||||
|
||||
it("400s on a non-numeric project id", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
const res = await GET(getRequest("abc"), params("abc"));
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: "Invalid project" });
|
||||
});
|
||||
|
||||
it("404s when the project does not exist", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
mockLoadProjectAuthzFacts.mockResolvedValue(null);
|
||||
const res = await GET(getRequest(), params());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s rather than 403s for a user outside the project", async () => {
|
||||
signedInAs([OTHER_ORG]);
|
||||
projectExists();
|
||||
const res = await GET(getRequest(), params());
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockListWorkstreamOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the catalogue with the project's selections", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
projectExists();
|
||||
const res = await GET(getRequest(), params());
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
workstreams: CATALOGUE,
|
||||
canManage: true,
|
||||
});
|
||||
expect(mockListWorkstreamOptions).toHaveBeenCalledWith(5n);
|
||||
});
|
||||
|
||||
it("lets a contractor read but marks the grid unmanageable", async () => {
|
||||
signedInAs([CONTRACTOR_ORG]);
|
||||
projectExists();
|
||||
const res = await GET(getRequest(), params());
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ canManage: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST", () => {
|
||||
it("403s for a contractor, who may see setup but not edit it", async () => {
|
||||
signedInAs([CONTRACTOR_ORG]);
|
||||
projectExists();
|
||||
const res = await POST(postRequest({ workstreamId: "2" }), params());
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toEqual({ error: "Forbidden" });
|
||||
expect(mockSelectWorkstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("400s on a body without a numeric workstreamId", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
projectExists();
|
||||
const res = await POST(postRequest({ workstreamId: "windows" }), params());
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: "Invalid body" });
|
||||
});
|
||||
|
||||
it("404s when the workstream is not in the catalogue", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
projectExists();
|
||||
mockWorkstreamExists.mockResolvedValue(false);
|
||||
const res = await POST(postRequest({ workstreamId: "99" }), params());
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockSelectWorkstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("201s on a new selection", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
projectExists();
|
||||
const res = await POST(postRequest({ workstreamId: "2" }), params());
|
||||
expect(res.status).toBe(201);
|
||||
expect(await res.json()).toEqual({
|
||||
projectWorkstreamId: "42",
|
||||
workstreamId: "2",
|
||||
selected: true,
|
||||
});
|
||||
expect(mockSelectWorkstream).toHaveBeenCalledWith(5n, 2n);
|
||||
});
|
||||
|
||||
it("200s when the workstream was already selected", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
projectExists();
|
||||
mockSelectWorkstream.mockResolvedValue({ id: 9n, created: false });
|
||||
const res = await POST(postRequest({ workstreamId: "1" }), params());
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ projectWorkstreamId: "9" });
|
||||
});
|
||||
|
||||
it("500s without leaking the driver error", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
projectExists();
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockSelectWorkstream.mockRejectedValue(new Error("connection reset"));
|
||||
const res = await POST(postRequest({ workstreamId: "2" }), params());
|
||||
expect(res.status).toBe(500);
|
||||
expect(await res.json()).toEqual({ error: "Couldn't add the workstream" });
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
72
src/app/api/projects/[projectId]/workstreams/route.ts
Normal file
72
src/app/api/projects/[projectId]/workstreams/route.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* `/api/projects/[projectId]/workstreams` (issue #410).
|
||||
*
|
||||
* GET — the seeded workstream catalogue, marked with this project's
|
||||
* selection and what is configured under each selected one.
|
||||
* POST — select a workstream (insert a `project_workstream` row).
|
||||
*
|
||||
* Deselect lives on `[workstreamId]/route.ts` because its cascade rules need
|
||||
* to name a single workstream.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { authorizeWorkstreamRequest } from "./authorize";
|
||||
import { listWorkstreamOptions, selectWorkstream, workstreamExists } from "./queries";
|
||||
|
||||
type Params = { params: Promise<{ projectId: string }> };
|
||||
|
||||
const selectSchema = z.object({
|
||||
/** `workstream.id` as a string — ids are bigint and do not survive JSON. */
|
||||
workstreamId: z.string().regex(/^\d+$/, "workstreamId must be a numeric id"),
|
||||
});
|
||||
|
||||
/** GET — the card grid's data, re-read from the DB on every request. */
|
||||
export async function GET(_req: NextRequest, props: Params) {
|
||||
const { projectId } = await props.params;
|
||||
const auth = await authorizeWorkstreamRequest(projectId, "view");
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const workstreams = await listWorkstreamOptions(auth.projectId);
|
||||
return NextResponse.json({ workstreams, canManage: auth.canManage });
|
||||
}
|
||||
|
||||
/** POST — select a workstream for the project. Idempotent. */
|
||||
export async function POST(req: NextRequest, props: Params) {
|
||||
const { projectId } = await props.params;
|
||||
const auth = await authorizeWorkstreamRequest(projectId, "manage");
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
let body: z.infer<typeof selectSchema>;
|
||||
try {
|
||||
body = selectSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
const workstreamId = BigInt(body.workstreamId);
|
||||
|
||||
if (!(await workstreamExists(workstreamId))) {
|
||||
return NextResponse.json({ error: "Workstream not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, created } = await selectWorkstream(auth.projectId, workstreamId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
projectWorkstreamId: id.toString(),
|
||||
workstreamId: body.workstreamId,
|
||||
selected: true,
|
||||
},
|
||||
{ status: created ? 201 : 200 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("POST /api/projects/[projectId]/workstreams failed:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Couldn't add the workstream" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/app/api/projects/route.ts
Normal file
80
src/app/api/projects/route.ts
Normal 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 });
|
||||
}
|
||||
166
src/app/components/DateInput.tsx
Normal file
166
src/app/components/DateInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
52
src/app/db/migrations/0276_romantic_synch.sql
Normal file
52
src/app/db/migrations/0276_romantic_synch.sql
Normal 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");
|
||||
2
src/app/db/migrations/0277_woozy_prism.sql
Normal file
2
src/app/db/migrations/0277_woozy_prism.sql
Normal 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";
|
||||
17
src/app/db/migrations/0278_living_baron_strucker.sql
Normal file
17
src/app/db/migrations/0278_living_baron_strucker.sql
Normal 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';
|
||||
3
src/app/db/migrations/0279_blushing_mattie_franklin.sql
Normal file
3
src/app/db/migrations/0279_blushing_mattie_franklin.sql
Normal 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';
|
||||
23
src/app/db/migrations/0280_flashy_dreadnoughts.sql
Normal file
23
src/app/db/migrations/0280_flashy_dreadnoughts.sql
Normal 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;
|
||||
13484
src/app/db/migrations/meta/0276_snapshot.json
Normal file
13484
src/app/db/migrations/meta/0276_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13472
src/app/db/migrations/meta/0277_snapshot.json
Normal file
13472
src/app/db/migrations/meta/0277_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13489
src/app/db/migrations/meta/0278_snapshot.json
Normal file
13489
src/app/db/migrations/meta/0278_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13492
src/app/db/migrations/meta/0279_snapshot.json
Normal file
13492
src/app/db/migrations/meta/0279_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13493
src/app/db/migrations/meta/0280_snapshot.json
Normal file
13493
src/app/db/migrations/meta/0280_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
244
src/app/db/seed/seed-projects-dashboard.ts
Normal file
244
src/app/db/seed/seed-projects-dashboard.ts
Normal 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());
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ?? [];
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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[]> = {};
|
||||
|
|
|
|||
298
src/app/projects/[projectId]/components/DashboardBands.tsx
Normal file
298
src/app/projects/[projectId]/components/DashboardBands.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
242
src/app/projects/[projectId]/import/components/ImportMapping.tsx
Normal file
242
src/app/projects/[projectId]/import/components/ImportMapping.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
380
src/app/projects/[projectId]/import/components/ImportUpload.tsx
Normal file
380
src/app/projects/[projectId]/import/components/ImportUpload.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
126
src/app/projects/[projectId]/import/components/MappingSelect.tsx
Normal file
126
src/app/projects/[projectId]/import/components/MappingSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
132
src/app/projects/[projectId]/import/page.tsx
Normal file
132
src/app/projects/[projectId]/import/page.tsx
Normal 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 1–2 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
93
src/app/projects/[projectId]/settings/page.tsx
Normal file
93
src/app/projects/[projectId]/settings/page.tsx
Normal 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
Loading…
Add table
Reference in a new issue