feat(ara-projects): import — contractor/stage columns + setup gate

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) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-22 16:36:16 +00:00
parent 9cfde2767f
commit 4204b1fe0e
7 changed files with 640 additions and 35 deletions

View file

@ -0,0 +1,104 @@
# 19. Work-order import is the setup-gated final wizard step, and carries contractor per row
Date: 2026-07-22
## Status
Accepted
Numbering note: 00150017 are in flight on the reporting-redesign branch, and
0010 alongside them (see ADR-0018's note). 0019 follows 0018 and avoids all of
them.
## Context
The Ara Projects "second import" (issue #414) matches a client-supplied
spreadsheet to Properties already in the DB and declares which Workstreams each
Property gets, as one row per (Property, Workstream). Committing those rows as
Work orders is #417.
Two questions came up once the schema (PR #404) and the setup wizard shape
(wireframes 0207) were concrete:
1. **When is a project allowed to import?** A `work_order` row is
`(property, project_workstream_stage, project_workstream_contractor,
reference, forecast_end)` — all NOT NULL except `forecast_end`. So a Work
order cannot exist for a workstream that has no stage ladder and no assigned
contractor. Importing against a half-configured project would fail row by
row at commit with nothing the user could act on up front.
2. **Where does each row's contractor come from?** The original #417 sketch
derived the contractor from the workstream: each `project_workstream` was
assumed to have a single contractor assignment, so the importer would look
it up and every Work order for that workstream went to that one org. But
`project_workstream_contractor` is a **collection** — a workstream can have
more than one contractor assigned (CONTEXT.md, "Contractor"), and real
programmes split one workstream's properties across several contractors by
area or batch. A single-assignee lookup cannot express that.
## Decision
**Import is the final, setup-gated step of the setup wizard.**
- The wizard's earlier steps select workstreams, configure each workstream's
stage ladder, and assign contractors. Import is the last step; #413 routes
the wizard into this same `/projects/[projectId]/import` screen. It also
stays reachable standalone from its own nav button.
- The screen is **gated on setup completeness** regardless of entry point.
Rule: every `project_workstream` on the project must have **≥1
`project_workstream_stage` AND ≥1 `project_workstream_contractor`**, and the
project must have at least one workstream. Until then the drop zone is
replaced by a "finish setup" state naming the incomplete workstreams and
linking back to configuration — not a 404 and not a silent empty zone.
- Evidence requirements (#412) are advisory and are **not** part of the gate.
- The gate is computed by `computeImportReadiness` (pure) /
`loadImportReadiness` (one read-only query) in
`src/lib/projects/import/readiness.ts`, returning per-workstream detail so
both this page and #413 can render *what* is missing.
**Contractor is supplied per import row.**
- The template gains a required `Contractor` column. Each row names the
contractor that delivers that (Property, Workstream), and it must match a
contractor **already assigned** to that workstream via
`project_workstream_contractor`. Validation (#417) rejects a contractor that
is not an existing assignment.
- **Contractor creation is out of scope for import.** Bringing a new company
into Ara and assigning it to a workstream is owned by the pre-existing
"invite company to Ara" flow; import only references assignments that flow
has already made. This is why the gate requires a contractor per workstream
before import opens — the assignments must exist first.
**Stage is optional per row.**
- The template gains an optional `Stage` column. A blank stage falls back at
commit to the workstream's first stage (`project_workstream_stage` ordered by
`order`, ascending — the ladder's start; see CONTEXT.md, "Stage"). A named
stage must match one on that workstream's ladder.
The raw parser (`parse.ts`) stays header-agnostic: it does not enforce the
required/optional distinction or that names resolve. Required-ness is the
mapping step's job (#415) and value resolution is the commit step's (#417).
## Consequences
- **Trade-off vs the replaced #417 design.** Deriving the contractor from the
workstream's single assignee was simpler — one fewer required column, and no
per-row contractor to validate. We give that up because it silently assumed
one contractor per workstream, which the schema does not guarantee and real
programmes violate. Carrying the contractor per row costs the client a column
they must fill and a validation failure mode ("contractor X isn't assigned to
workstream Y"), but it is the only shape that supports a workstream split
across contractors, and it keeps the assignment set — not the spreadsheet —
as the source of truth for who may receive work.
- The setup gate means a project cannot reach the drop zone mid-configuration.
That is the intended safety property (no un-committable imports), but it does
make import unreachable until contractors are assigned — acceptable because
assignment is a prerequisite for issuing any work order at all.
- `readiness.ts` has two consumers (this page and #413), so its interface is
kept deliberately stable and detail-rich.
- Not yet decided here: how a row's named contractor disambiguates when the
same org is assigned to a workstream more than once, and the exact
stage-name matching rule. Both are #417's to settle at commit; this ADR fixes
only that the contractor is per-row-and-pre-assigned and the stage is
optional-with-first-stage-fallback.

View file

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

View file

@ -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 (
<div className="max-w-4xl mx-auto px-6 py-10">
<div className="mb-8">
@ -51,13 +74,21 @@ export default async function ImportPage(props: {
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 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.
</p>
</div>
<ImportUpload projectId={projectId} />
{readiness.ready ? (
<ImportUpload projectId={projectId} />
) : (
<ImportSetupIncomplete
readiness={readiness}
setupHref={setupHref(projectId)}
/>
)}
<div className="mt-10 grid gap-4 sm:grid-cols-3">
{[

View file

@ -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", () => {

View file

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

View file

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

View file

@ -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<ImportReadiness> {
const rows = await db
.select({
projectWorkstreamId: projectWorkstream.id,
workstreamId: projectWorkstream.workstreamId,
workstreamName: workstream.name,
stageCount: sql<string>`count(distinct ${projectWorkstreamStage.id})`,
contractorCount: sql<string>`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),
})),
);
}