From 4204b1fe0e486d3a0836f7368730a7ead1987007 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 22 Jul 2026 16:36:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(ara-projects):=20import=20=E2=80=94=20cont?= =?UTF-8?q?ractor/stage=20columns=20+=20setup=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the #414 upload-and-parse work (does not rewrite it) with a per-row contractor, an optional stage, and a setup-completeness gate on the import page. New ADR-0019 records the design. Still writes nothing. CHANGE 1 — template + parser expected fields Final column order: Property identifier, Workstream, Contractor, Stage, Work order reference (optional), Forecast end date (optional). Contractor is required; Stage is optional (blank falls back to the workstream's first stage at commit). buildImportTemplateCsv, its worked example and the field docs are updated. The raw parser stays header-agnostic — required/optional is enforced in mapping (#415) and validation/commit (#417), not here — with a test proving a file missing the required Contractor column still parses. CHANGE 2 — setup-completeness gate (the substantive feature) src/lib/projects/import/readiness.ts: a pure computeImportReadiness(facts) plus a read-only loadImportReadiness(projectId) round trip. Rule: every project_workstream must have >=1 project_workstream_stage AND >=1 project_workstream_contractor, and the project must have >=1 workstream. Evidence requirements (#412) are advisory and excluded from the gate. The helper returns per-workstream detail (which are incomplete, and why) and is built to be reused by #413's final wizard step — noted in a comment. The import page loads readiness and, when not ready, renders an ImportSetupIncomplete state (lists the incomplete workstreams with the reason, links to project setup) instead of the drop zone — not a 404, not a silent empty zone. When ready, the drop zone renders as before. The gate applies on every entry: the route stays reachable standalone, and a comment notes #413 will also route the wizard into it (that wiring is out of scope here). CHANGE 3 — ADR-0019 Records: import is the final, setup-gated wizard step; contractor is supplied per row and must match a contractor already assigned to the workstream (contractor creation is out of scope — the existing "invite company to Ara" flow owns it); stage is optional per row with first-stage fallback. States the trade-off vs the replaced #417 design, which derived the contractor from the workstream's single assignee — abandoned because project_workstream_contractor is a collection, so one workstream's properties can be split across contractors, which a single-assignee lookup cannot express. Notes on the readiness query count(distinct project_workstream_stage.id) / (…contractor.id) over two left joins — the distinct is load-bearing because joining both children at once makes a stages x contractors cartesian product a plain count(*) would over-count. count(distinct) returns a bigint that node-postgres hands back as a string, so each count is coerced with Number(); a loader test drives a mocked db to prove that coercion. Production-DB safety No migrations, seeds or writes. loadImportReadiness is read-only SELECTs and is not executed here. Unit tests mock @/app/db/db (vi.mock) and open no connection; the pure readiness logic is tested with in-memory fixtures. Full suite: 575 passing. tsc and eslint clean. authz modules and src/app/projects/page.tsx untouched (other branches own them). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...up-gated-and-carries-contractor-per-row.md | 104 +++++++++++ .../components/ImportSetupIncomplete.tsx | 76 ++++++++ src/app/projects/[projectId]/import/page.tsx | 43 ++++- src/lib/projects/import/parse.test.ts | 68 +++++-- src/lib/projects/import/parse.ts | 47 +++-- src/lib/projects/import/readiness.test.ts | 171 ++++++++++++++++++ src/lib/projects/import/readiness.ts | 166 +++++++++++++++++ 7 files changed, 640 insertions(+), 35 deletions(-) create mode 100644 docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md create mode 100644 src/app/projects/[projectId]/import/components/ImportSetupIncomplete.tsx create mode 100644 src/lib/projects/import/readiness.test.ts create mode 100644 src/lib/projects/import/readiness.ts diff --git a/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md b/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md new file mode 100644 index 00000000..502275df --- /dev/null +++ b/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md @@ -0,0 +1,104 @@ +# 19. Work-order import is the setup-gated final wizard step, and carries contractor per row + +Date: 2026-07-22 + +## Status + +Accepted + +Numbering note: 0015–0017 are in flight on the reporting-redesign branch, and +0010 alongside them (see ADR-0018's note). 0019 follows 0018 and avoids all of +them. + +## Context + +The Ara Projects "second import" (issue #414) matches a client-supplied +spreadsheet to Properties already in the DB and declares which Workstreams each +Property gets, as one row per (Property, Workstream). Committing those rows as +Work orders is #417. + +Two questions came up once the schema (PR #404) and the setup wizard shape +(wireframes 02–07) were concrete: + +1. **When is a project allowed to import?** A `work_order` row is + `(property, project_workstream_stage, project_workstream_contractor, + reference, forecast_end)` — all NOT NULL except `forecast_end`. So a Work + order cannot exist for a workstream that has no stage ladder and no assigned + contractor. Importing against a half-configured project would fail row by + row at commit with nothing the user could act on up front. + +2. **Where does each row's contractor come from?** The original #417 sketch + derived the contractor from the workstream: each `project_workstream` was + assumed to have a single contractor assignment, so the importer would look + it up and every Work order for that workstream went to that one org. But + `project_workstream_contractor` is a **collection** — a workstream can have + more than one contractor assigned (CONTEXT.md, "Contractor"), and real + programmes split one workstream's properties across several contractors by + area or batch. A single-assignee lookup cannot express that. + +## Decision + +**Import is the final, setup-gated step of the setup wizard.** + +- The wizard's earlier steps select workstreams, configure each workstream's + stage ladder, and assign contractors. Import is the last step; #413 routes + the wizard into this same `/projects/[projectId]/import` screen. It also + stays reachable standalone from its own nav button. +- The screen is **gated on setup completeness** regardless of entry point. + Rule: every `project_workstream` on the project must have **≥1 + `project_workstream_stage` AND ≥1 `project_workstream_contractor`**, and the + project must have at least one workstream. Until then the drop zone is + replaced by a "finish setup" state naming the incomplete workstreams and + linking back to configuration — not a 404 and not a silent empty zone. +- Evidence requirements (#412) are advisory and are **not** part of the gate. +- The gate is computed by `computeImportReadiness` (pure) / + `loadImportReadiness` (one read-only query) in + `src/lib/projects/import/readiness.ts`, returning per-workstream detail so + both this page and #413 can render *what* is missing. + +**Contractor is supplied per import row.** + +- The template gains a required `Contractor` column. Each row names the + contractor that delivers that (Property, Workstream), and it must match a + contractor **already assigned** to that workstream via + `project_workstream_contractor`. Validation (#417) rejects a contractor that + is not an existing assignment. +- **Contractor creation is out of scope for import.** Bringing a new company + into Ara and assigning it to a workstream is owned by the pre-existing + "invite company to Ara" flow; import only references assignments that flow + has already made. This is why the gate requires a contractor per workstream + before import opens — the assignments must exist first. + +**Stage is optional per row.** + +- The template gains an optional `Stage` column. A blank stage falls back at + commit to the workstream's first stage (`project_workstream_stage` ordered by + `order`, ascending — the ladder's start; see CONTEXT.md, "Stage"). A named + stage must match one on that workstream's ladder. + +The raw parser (`parse.ts`) stays header-agnostic: it does not enforce the +required/optional distinction or that names resolve. Required-ness is the +mapping step's job (#415) and value resolution is the commit step's (#417). + +## Consequences + +- **Trade-off vs the replaced #417 design.** Deriving the contractor from the + workstream's single assignee was simpler — one fewer required column, and no + per-row contractor to validate. We give that up because it silently assumed + one contractor per workstream, which the schema does not guarantee and real + programmes violate. Carrying the contractor per row costs the client a column + they must fill and a validation failure mode ("contractor X isn't assigned to + workstream Y"), but it is the only shape that supports a workstream split + across contractors, and it keeps the assignment set — not the spreadsheet — + as the source of truth for who may receive work. +- The setup gate means a project cannot reach the drop zone mid-configuration. + That is the intended safety property (no un-committable imports), but it does + make import unreachable until contractors are assigned — acceptable because + assignment is a prerequisite for issuing any work order at all. +- `readiness.ts` has two consumers (this page and #413), so its interface is + kept deliberately stable and detail-rich. +- Not yet decided here: how a row's named contractor disambiguates when the + same org is assigned to a workstream more than once, and the exact + stage-name matching rule. Both are #417's to settle at commit; this ADR fixes + only that the contractor is per-row-and-pre-assigned and the stage is + optional-with-first-stage-fallback. diff --git a/src/app/projects/[projectId]/import/components/ImportSetupIncomplete.tsx b/src/app/projects/[projectId]/import/components/ImportSetupIncomplete.tsx new file mode 100644 index 00000000..b175c323 --- /dev/null +++ b/src/app/projects/[projectId]/import/components/ImportSetupIncomplete.tsx @@ -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 ( +
+
+ +
+

+ Finish project setup first +

+

+ {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."} +

+ + {readiness.incompleteWorkstreams.length > 0 && ( +
    + {readiness.incompleteWorkstreams.map((w) => ( +
  • + + {w.workstreamName} + + + {w.missing.map((gap) => GAP_LABEL[gap]).join(" · ")} + +
  • + ))} +
+ )} + + + Go to project setup + +
+
+
+ ); +} diff --git a/src/app/projects/[projectId]/import/page.tsx b/src/app/projects/[projectId]/import/page.tsx index 627f93c4..556ebc10 100644 --- a/src/app/projects/[projectId]/import/page.tsx +++ b/src/app/projects/[projectId]/import/page.tsx @@ -5,7 +5,9 @@ import { loadAuthzUser, loadProjectAuthzFacts, } from "@/app/repositories/projects/authzRepository"; +import { loadImportReadiness } from "@/lib/projects/import/readiness"; import { ImportUpload } from "./components/ImportUpload"; +import { ImportSetupIncomplete } from "./components/ImportSetupIncomplete"; export const metadata = { title: "Import programme data | Ara", @@ -15,14 +17,32 @@ export const metadata = { * Work-order import, step 1 of 3 — upload and parse (issue #414). * * Wireframe: `docs/wireframes/ara-projects/1-admin-import/07-import-upload.html`. - * The mapping step (#415), validation (#416) and commit (#417) follow; this - * page ends at a parsed preview and writes nothing. + * The mapping step (#415), validation and commit (#417) follow; this page ends + * at a parsed preview and 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 }>; }) { @@ -38,6 +58,9 @@ export default async function ImportPage(props: { // 404 rather than 403 throughout, so project existence stays private. if (!user || !project || !canManageProject(user, project)) notFound(); + // Read-only: gather whether the project is set up enough to import against. + const readiness = await loadImportReadiness(BigInt(projectId)); + return (
@@ -51,13 +74,21 @@ export default async function ImportPage(props: { Import programme data

- Upload a file listing which workstreams each property takes — one row - per property and workstream. A property taking Windows and Doors - appears twice, sharing its identifier. + 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.

- + {readiness.ready ? ( + + ) : ( + + )}
{[ diff --git a/src/lib/projects/import/parse.test.ts b/src/lib/projects/import/parse.test.ts index 3ff07100..9e87c605 100644 --- a/src/lib/projects/import/parse.test.ts +++ b/src/lib/projects/import/parse.test.ts @@ -28,8 +28,8 @@ describe("parseImportSheet", () => { it("returns headers, rows, count and a preview", () => { const result = parseImportSheet([ HEADERS, - ["PROP-0001", "Windows", "WO-1001", "2026-09-30"], - ["PROP-0002", "Roofs", "", ""], + ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"], + ["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""], ]); expect(result).toEqual({ @@ -37,13 +37,13 @@ describe("parseImportSheet", () => { file: { headers: HEADERS, rows: [ - ["PROP-0001", "Windows", "WO-1001", "2026-09-30"], - ["PROP-0002", "Roofs", "", ""], + ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"], + ["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""], ], rowCount: 2, preview: [ - ["PROP-0001", "Windows", "WO-1001", "2026-09-30"], - ["PROP-0002", "Roofs", "", ""], + ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"], + ["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""], ], }, }); @@ -102,16 +102,17 @@ describe("parseImportSheet", () => { it("trims cells, drops blank rows, and pads ragged ones", () => { const result = parseImportSheet([ HEADERS, - [" PROP-0001 ", " Windows ", "", ""], - ["", "", "", ""], - ["PROP-0002", "Doors"], + [" PROP-0001 ", " Windows ", " Acme Windows Ltd ", "", "", ""], + ["", "", "", "", "", ""], + ["PROP-0002", "Doors", "Acme Doors Ltd"], ]); expect(result.ok).toBe(true); if (!result.ok) return; + // Short rows pad out to the 6-column header width. expect(result.file.rows).toEqual([ - ["PROP-0001", "Windows", "", ""], - ["PROP-0002", "Doors", "", ""], + ["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""], + ["PROP-0002", "Doors", "Acme Doors Ltd", "", "", ""], ]); }); @@ -141,7 +142,7 @@ describe("parseImportSheet", () => { it("caps the preview at IMPORT_PREVIEW_ROWS but keeps every row", () => { const rows: string[][] = [HEADERS]; for (let i = 0; i < IMPORT_PREVIEW_ROWS + 5; i++) { - rows.push([`PROP-${i}`, "Windows", "", ""]); + rows.push([`PROP-${i}`, "Windows", "Acme Windows Ltd", "", "", ""]); } const result = parseImportSheet(rows); @@ -150,7 +151,14 @@ describe("parseImportSheet", () => { expect(result.file.rowCount).toBe(IMPORT_PREVIEW_ROWS + 5); expect(result.file.rows).toHaveLength(IMPORT_PREVIEW_ROWS + 5); expect(result.file.preview).toHaveLength(IMPORT_PREVIEW_ROWS); - expect(result.file.preview[0]).toEqual(["PROP-0", "Windows", "", ""]); + expect(result.file.preview[0]).toEqual([ + "PROP-0", + "Windows", + "Acme Windows Ltd", + "", + "", + "", + ]); }); it("errors on an entirely empty sheet", () => { @@ -215,7 +223,10 @@ describe("parseImportSheet", () => { describe("parseImportFile", () => { it("parses a real XLSX buffer", () => { const buf = workbookBuffer( - [HEADERS, ["PROP-0001", "Windows", "WO-1001", "2026-09-30"]], + [ + HEADERS, + ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"], + ], "xlsx", ); @@ -224,20 +235,22 @@ describe("parseImportFile", () => { if (!result.ok) return; expect(result.file.headers).toEqual(HEADERS); expect(result.file.rows).toEqual([ - ["PROP-0001", "Windows", "WO-1001", "2026-09-30"], + ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"], ]); }); it("parses a real CSV buffer", () => { const buf = workbookBuffer( - [HEADERS, ["PROP-0001", "Windows", "", ""]], + [HEADERS, ["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""]], "csv", ); const result = parseImportFile(buf); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.file.rows).toEqual([["PROP-0001", "Windows", "", ""]]); + expect(result.file.rows).toEqual([ + ["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""], + ]); }); it("keeps the leading zero on a numeric-looking identifier", () => { @@ -262,11 +275,30 @@ describe("parseImportFile", () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.file.headers).toEqual(HEADERS); - // The template demonstrates one property taking two workstreams. + // The template demonstrates one property taking two workstreams... expect(result.file.rows[0][0]).toBe("PROP-0001"); expect(result.file.rows[1][0]).toBe("PROP-0001"); expect(result.file.rows[0][1]).toBe("Windows"); expect(result.file.rows[1][1]).toBe("Doors"); + // ...with a Contractor on every row (required) and Stage blank on the + // second (optional, defaults to the workstream's first stage). + expect(result.file.rows[0][2]).toBe("Acme Windows Ltd"); + expect(result.file.rows[1][2]).toBe("Acme Doors Ltd"); + expect(result.file.rows[0][3]).toBe("Ordered"); + expect(result.file.rows[1][3]).toBe(""); + }); + + it("stays header-agnostic when the required Contractor column is absent", () => { + // The raw parser does not enforce required-ness — a file missing Contractor + // still parses cleanly, and the mapping (#415) / commit (#417) steps are + // what reject it. Enforcing here would make the mapping step unreachable. + const result = parseImportFile( + new TextEncoder().encode("Property identifier,Workstream\nPROP-1,Windows\n"), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.file.headers).toEqual(["Property identifier", "Workstream"]); }); it("rejects empty and single-line rubbish with a friendly error", () => { diff --git a/src/lib/projects/import/parse.ts b/src/lib/projects/import/parse.ts index 3f31f589..40a0da1a 100644 --- a/src/lib/projects/import/parse.ts +++ b/src/lib/projects/import/parse.ts @@ -14,10 +14,12 @@ * * Scope note: this module is **header-agnostic on purpose**. It reports the * headers it found and hands back the rows; it does not insist on the template - * columns and does not validate cell contents. Mapping arbitrary client headings - * onto the template columns is the mapping step (#415), and validating the - * mapped values is #416. Rejecting an unrecognised heading here would make the - * mapping step unreachable for exactly the files that need it most. + * columns and does not validate cell contents. In particular it does **not** + * enforce that `Contractor` is present or that `Workstream`/`Contractor` name a + * real assignment — required/optional is the mapping step's job (#415) and + * value validation is the commit step's (#417). Rejecting an unrecognised + * heading here would make the mapping step unreachable for exactly the files + * that need it most. */ import * as XLSX from "xlsx"; @@ -26,11 +28,26 @@ import * as XLSX from "xlsx"; * The columns the "Download template" artefact ships with, in order. * * `Property identifier` is the landlord property id *or* the UPRN — matching - * decides which at validation time (#416). The last two are optional per row. + * decides which at validation time (#417). + * + * Required vs optional (enforced downstream, **not** here — see the module + * header; this list is only the template's shape): + * - `Property identifier` — required. + * - `Workstream` — required; must be one the project selected. + * - `Contractor` — required; must match a contractor already + * assigned to that workstream (ADR-0019). + * Contractor creation is out of scope — the + * existing "invite company to Ara" flow owns it. + * - `Stage` — optional; blank falls back to the workstream's + * first stage at commit (ADR-0019). + * - `Work order reference`— optional per row. + * - `Forecast end date` — optional per row. */ export const IMPORT_TEMPLATE_COLUMNS = [ "Property identifier", "Workstream", + "Contractor", + "Stage", "Work order reference", "Forecast end date", ] as const; @@ -238,17 +255,25 @@ export function hasAcceptedImportExtension(filename: string): boolean { } /** - * The "Download template" artefact: a CSV carrying the expected headings plus - * one worked example showing a Property that takes two Workstreams — the file - * shape's one non-obvious rule, demonstrated rather than explained. + * The "Download template" artefact: a CSV carrying the expected headings plus a + * worked example that demonstrates the file shape's non-obvious rules rather + * than explaining them: + * - a Property taking two Workstreams appears on two rows sharing its + * identifier (rows 1 and 2, `PROP-0001`); + * - `Contractor` is filled on every row (it is required, and must already be + * assigned to that workstream — ADR-0019); + * - `Stage` is blank on rows 2 and 3 to show it is optional (it falls back to + * the workstream's first stage at commit); + * - `Work order reference` and `Forecast end date` are blank on the last row + * to show they are optional too. * * CSV rather than XLSX so it opens the same everywhere and stays diffable. */ export function buildImportTemplateCsv(): string { const example = [ - ["PROP-0001", "Windows", "WO-1001", "2026-09-30"], - ["PROP-0001", "Doors", "WO-1002", "2026-10-15"], - ["PROP-0002", "Roofs", "", ""], + ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"], + ["PROP-0001", "Doors", "Acme Doors Ltd", "", "WO-1002", "2026-10-15"], + ["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""], ]; return ( [[...IMPORT_TEMPLATE_COLUMNS], ...example] diff --git a/src/lib/projects/import/readiness.test.ts b/src/lib/projects/import/readiness.test.ts new file mode 100644 index 00000000..c7a6bc3c --- /dev/null +++ b/src/lib/projects/import/readiness.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from "vitest"; + +// The module under test imports `@/app/db/db` for its `loadImportReadiness` +// loader. Mock it so importing the module opens no connection and the +// production database is never touched — the pure `computeImportReadiness` +// tests below never reach it, and the one loader test drives it explicitly. +const mockSelect = vi.fn(); +vi.mock("@/app/db/db", () => ({ db: { select: () => mockSelect() } })); + +import { + computeImportReadiness, + loadImportReadiness, + type WorkstreamReadinessFacts, +} from "./readiness"; + +function facts( + over: Partial & { workstreamName: string }, +): WorkstreamReadinessFacts { + return { + projectWorkstreamId: 1n, + workstreamId: 10n, + stageCount: 1, + contractorCount: 1, + ...over, + }; +} + +describe("computeImportReadiness", () => { + it("is ready when every workstream has a stage and a contractor", () => { + const result = computeImportReadiness([ + facts({ workstreamName: "Windows", projectWorkstreamId: 1n }), + facts({ workstreamName: "Doors", projectWorkstreamId: 2n }), + ]); + + expect(result.ready).toBe(true); + expect(result.hasWorkstreams).toBe(true); + expect(result.incompleteWorkstreams).toEqual([]); + expect(result.workstreams.every((w) => w.complete)).toBe(true); + expect(result.workstreams[0].missing).toEqual([]); + }); + + it("is not ready, and reports 'contractor', when a workstream has no contractor", () => { + const result = computeImportReadiness([ + facts({ workstreamName: "Windows" }), + facts({ workstreamName: "Doors", projectWorkstreamId: 2n, contractorCount: 0 }), + ]); + + expect(result.ready).toBe(false); + expect(result.incompleteWorkstreams).toHaveLength(1); + const doors = result.incompleteWorkstreams[0]; + expect(doors.workstreamName).toBe("Doors"); + expect(doors.complete).toBe(false); + expect(doors.missing).toEqual(["contractor"]); + }); + + it("is not ready, and reports 'stages', when a workstream has no stage", () => { + const result = computeImportReadiness([ + facts({ workstreamName: "Roofs", stageCount: 0 }), + ]); + + expect(result.ready).toBe(false); + expect(result.incompleteWorkstreams[0].missing).toEqual(["stages"]); + }); + + it("reports both gaps, stages before contractor, when a workstream has neither", () => { + const result = computeImportReadiness([ + facts({ workstreamName: "Heating", stageCount: 0, contractorCount: 0 }), + ]); + + expect(result.incompleteWorkstreams[0].missing).toEqual([ + "stages", + "contractor", + ]); + }); + + it("lists only the incomplete workstreams, but keeps all in `workstreams`", () => { + const result = computeImportReadiness([ + facts({ workstreamName: "Windows", projectWorkstreamId: 1n }), + facts({ workstreamName: "Doors", projectWorkstreamId: 2n, stageCount: 0 }), + facts({ workstreamName: "Roofs", projectWorkstreamId: 3n, contractorCount: 0 }), + ]); + + expect(result.workstreams).toHaveLength(3); + expect(result.incompleteWorkstreams.map((w) => w.workstreamName)).toEqual([ + "Doors", + "Roofs", + ]); + }); + + it("is not ready when the project has selected no workstreams", () => { + // Vacuously "all complete", but there is nothing to import into. + const result = computeImportReadiness([]); + + expect(result.ready).toBe(false); + expect(result.hasWorkstreams).toBe(false); + expect(result.workstreams).toEqual([]); + expect(result.incompleteWorkstreams).toEqual([]); + }); + + it("treats counts above one as complete (not just exactly one)", () => { + const result = computeImportReadiness([ + facts({ workstreamName: "Windows", stageCount: 5, contractorCount: 3 }), + ]); + + expect(result.ready).toBe(true); + }); +}); + +describe("loadImportReadiness", () => { + it("coerces string counts from the driver and decides via the pure function", async () => { + // node-postgres returns count(distinct …) as a string; the loader must + // Number() it or every workstream would read as complete (Number("0") vs + // "0" truthiness). Drive the mocked query chain to prove the coercion. + const groupBy = vi.fn().mockResolvedValue([ + { + projectWorkstreamId: 1n, + workstreamId: 10n, + workstreamName: "Windows", + stageCount: "2", + contractorCount: "1", + }, + { + projectWorkstreamId: 2n, + workstreamId: 11n, + workstreamName: "Doors", + stageCount: "1", + contractorCount: "0", + }, + ]); + const chain = { + from: () => chain, + innerJoin: () => chain, + leftJoin: () => chain, + where: () => chain, + groupBy, + }; + mockSelect.mockReturnValue(chain); + + const result = await loadImportReadiness(42n); + + expect(groupBy).toHaveBeenCalledOnce(); + expect(result.ready).toBe(false); + expect(result.workstreams[0]).toMatchObject({ + workstreamName: "Windows", + stageCount: 2, + contractorCount: 1, + complete: true, + }); + expect(result.incompleteWorkstreams).toHaveLength(1); + expect(result.incompleteWorkstreams[0]).toMatchObject({ + workstreamName: "Doors", + missing: ["contractor"], + }); + }); + + it("reports not-ready for a project with no workstream rows", async () => { + const chain = { + from: () => chain, + innerJoin: () => chain, + leftJoin: () => chain, + where: () => chain, + groupBy: vi.fn().mockResolvedValue([]), + }; + mockSelect.mockReturnValue(chain); + + const result = await loadImportReadiness(7n); + + expect(result.ready).toBe(false); + expect(result.hasWorkstreams).toBe(false); + }); +}); diff --git a/src/lib/projects/import/readiness.ts b/src/lib/projects/import/readiness.ts new file mode 100644 index 00000000..b061ff23 --- /dev/null +++ b/src/lib/projects/import/readiness.ts @@ -0,0 +1,166 @@ +/** + * Import readiness — is a Project set up enough to import work orders against? + * (issue #414, extended.) + * + * The gate (ADR-0019): **every** workstream the project selected + * (`project_workstream`) must have BOTH at least one stage + * (`project_workstream_stage`) AND at least one assigned contractor + * (`project_workstream_contractor`). A project with no workstreams is not + * importable either — there is nothing to import into. Evidence requirements + * (#412) are advisory and deliberately play no part in this gate. + * + * The module is split the way the rest of Ara Projects splits domain from + * persistence (see `@/lib/projects/authz` + its repository): + * - `computeImportReadiness` is **pure** — it takes per-workstream facts and + * decides. It imports no database client, so it unit-tests against + * in-memory fixtures with no connection. + * - `loadImportReadiness` is the single read-only round trip that gathers + * those facts for one project and hands them to the pure function. + * + * ## Reused by #413 + * + * The final step of the Ara Projects setup wizard (#413) routes into this same + * import screen, so it needs the identical "is setup complete?" answer this + * page needs. Both call `loadImportReadiness(projectId)` and branch on + * `ImportReadiness.ready`; the `incompleteWorkstreams` detail is what each + * surface renders to tell the user what is still missing. Keep this interface + * stable for that reason — it has a second consumer. + */ + +import { db } from "@/app/db/db"; +import { eq, sql } from "drizzle-orm"; +import { + projectWorkstream, + projectWorkstreamContractor, + projectWorkstreamStage, + workstream, +} from "@/app/db/schema/projects/projects"; + +/** The two things a workstream can be missing before the project can import. */ +export type ImportReadinessGap = "stages" | "contractor"; + +/** The raw per-workstream facts the readiness decision is computed from. */ +export interface WorkstreamReadinessFacts { + /** `project_workstream.id`. */ + projectWorkstreamId: bigint; + /** `project_workstream.workstream_id` — the reference-data workstream. */ + workstreamId: bigint; + /** `workstream.name`, for rendering which workstreams are incomplete. */ + workstreamName: string; + /** Count of `project_workstream_stage` rows for this workstream. */ + stageCount: number; + /** Count of `project_workstream_contractor` rows for this workstream. */ + contractorCount: number; +} + +/** One workstream's readiness, derived from its facts. */ +export interface WorkstreamReadiness { + projectWorkstreamId: bigint; + workstreamId: bigint; + workstreamName: string; + stageCount: number; + contractorCount: number; + /** True when this workstream has both a stage and a contractor. */ + complete: boolean; + /** Which requirements are unmet — empty when `complete`. In stable order. */ + missing: ImportReadinessGap[]; +} + +/** The whole-project readiness verdict. */ +export interface ImportReadiness { + /** + * True only when the project has at least one workstream and every workstream + * is complete. This is the gate the import page and #413 branch on. + */ + ready: boolean; + /** False when the project has selected no workstreams at all. */ + hasWorkstreams: boolean; + /** Every workstream's readiness, in the order the facts arrived. */ + workstreams: WorkstreamReadiness[]; + /** Just the incomplete ones — what the "finish setup" state lists. */ + incompleteWorkstreams: WorkstreamReadiness[]; +} + +/** + * Decide, from per-workstream facts, whether a project may import — pure, no + * database. A workstream is complete when it has ≥1 stage AND ≥1 contractor; + * the project is ready when it has workstreams and all of them are complete. + */ +export function computeImportReadiness( + facts: WorkstreamReadinessFacts[], +): ImportReadiness { + const workstreams: WorkstreamReadiness[] = facts.map((f) => { + const missing: ImportReadinessGap[] = []; + // Order matters only for stable rendering: stages before contractor. + if (f.stageCount < 1) missing.push("stages"); + if (f.contractorCount < 1) missing.push("contractor"); + return { + projectWorkstreamId: f.projectWorkstreamId, + workstreamId: f.workstreamId, + workstreamName: f.workstreamName, + stageCount: f.stageCount, + contractorCount: f.contractorCount, + complete: missing.length === 0, + missing, + }; + }); + + const incompleteWorkstreams = workstreams.filter((w) => !w.complete); + const hasWorkstreams = workstreams.length > 0; + + return { + ready: hasWorkstreams && incompleteWorkstreams.length === 0, + hasWorkstreams, + workstreams, + incompleteWorkstreams, + }; +} + +/** + * Gather one project's per-workstream readiness facts and decide. A single + * read-only round trip: it writes nothing. + * + * The stage and contractor counts come from `count(distinct …)` over two left + * joins — the distinct is load-bearing, because joining both children at once + * produces a stages×contractors cartesian product per workstream that a plain + * `count(*)` would over-count. `count(distinct)` returns a bigint, which + * node-postgres hands back as a string, so each is coerced with `Number`. + */ +export async function loadImportReadiness( + projectId: bigint, +): Promise { + const rows = await db + .select({ + projectWorkstreamId: projectWorkstream.id, + workstreamId: projectWorkstream.workstreamId, + workstreamName: workstream.name, + stageCount: sql`count(distinct ${projectWorkstreamStage.id})`, + contractorCount: sql`count(distinct ${projectWorkstreamContractor.id})`, + }) + .from(projectWorkstream) + .innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id)) + .leftJoin( + projectWorkstreamStage, + eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id), + ) + .leftJoin( + projectWorkstreamContractor, + eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id), + ) + .where(eq(projectWorkstream.projectId, projectId)) + .groupBy( + projectWorkstream.id, + projectWorkstream.workstreamId, + workstream.name, + ); + + return computeImportReadiness( + rows.map((r) => ({ + projectWorkstreamId: r.projectWorkstreamId, + workstreamId: r.workstreamId, + workstreamName: r.workstreamName, + stageCount: Number(r.stageCount), + contractorCount: Number(r.contractorCount), + })), + ); +}