mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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>
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
/**
|
|
* Work-order import — file parsing (issue #414, step 1 of 3).
|
|
*
|
|
* The "second import": properties already exist in the DB (via BulkUpload or
|
|
* postcode search); this file declares **which Workstreams each Property
|
|
* gets**. One row per (Property, Workstream) — a Property taking Windows and
|
|
* Doors appears twice, sharing an identifier. Grouping those duplicates is the
|
|
* commit step's job (#417), not this module's.
|
|
*
|
|
* Deliberately **pure and DB-free**, mirroring `@/lib/tags/bulkAssign`: it takes
|
|
* bytes or a 2D array of cells and returns data or a friendly `error` string.
|
|
* That keeps it unit-testable without a connection pool, and keeps the route
|
|
* handler down to authorization plus a function call.
|
|
*
|
|
* 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. 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";
|
|
|
|
/**
|
|
* 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 (#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;
|
|
|
|
/**
|
|
* Upper bound on the upload, enforced client- and server-side.
|
|
*
|
|
* The wireframe says 50MB; 10MB is well past what this shape of file can
|
|
* plausibly reach (four narrow columns, one row per property-workstream), and
|
|
* it keeps the whole-file-in-memory parse honest.
|
|
*/
|
|
export const MAX_IMPORT_FILE_BYTES = 10 * 1024 * 1024;
|
|
|
|
/** Upper bound on data rows, so an accidental 500k-row export fails fast. */
|
|
export const MAX_IMPORT_ROWS = 20_000;
|
|
|
|
/** How many rows the preview carries back for the mapping step. */
|
|
export const IMPORT_PREVIEW_ROWS = 10;
|
|
|
|
/** Extensions offered by the drop zone and re-checked on the server. */
|
|
export const ACCEPTED_IMPORT_EXTENSIONS = [".csv", ".xlsx", ".xls"] as const;
|
|
|
|
export interface ParsedImportFile {
|
|
/** Header row, trimmed, in file order. Trailing blank columns dropped. */
|
|
headers: string[];
|
|
/**
|
|
* Every data row, each padded/truncated to `headers.length`. All-blank rows
|
|
* are dropped. Cell values are trimmed strings — never numbers, so a numeric
|
|
* identifier keeps its leading zeros.
|
|
*/
|
|
rows: string[][];
|
|
/** `rows.length`, carried explicitly so callers need not hold `rows` to show it. */
|
|
rowCount: number;
|
|
/** The first `IMPORT_PREVIEW_ROWS` of `rows`, for the mapping step's sample table. */
|
|
preview: string[][];
|
|
}
|
|
|
|
export type ParseImportResult =
|
|
| { ok: true; file: ParsedImportFile }
|
|
| { ok: false; error: string };
|
|
|
|
/** What SheetJS hands back per cell before we normalise it. */
|
|
type Cell = string | number | boolean | null | undefined;
|
|
|
|
/**
|
|
* Read the first sheet of a CSV/XLSX/XLS buffer into rows of cells.
|
|
*
|
|
* `raw: false` makes SheetJS return each cell's *displayed* text rather than an
|
|
* inferred type — so identifier `"07510027001"` survives instead of being
|
|
* coerced to the number 7510027001 with its leading zero dropped. `defval: ""`
|
|
* keeps ragged rows aligned to their column positions rather than collapsing
|
|
* gaps. Both match the conventions in `@/lib/tags/bulkAssign` and
|
|
* `@/lib/bulkUpload/addressMatches`.
|
|
*
|
|
* Throws whatever SheetJS throws on an unreadable file — `parseImportFile`
|
|
* turns that into a friendly error.
|
|
*/
|
|
export function readImportSheet(buf: ArrayBuffer | Uint8Array): Cell[][] {
|
|
const wb = XLSX.read(buf, { type: "array" });
|
|
const sheetName = wb.SheetNames[0];
|
|
if (!sheetName) return [];
|
|
const sheet = wb.Sheets[sheetName];
|
|
if (!sheet) return [];
|
|
return XLSX.utils.sheet_to_json(sheet, {
|
|
header: 1,
|
|
raw: false,
|
|
defval: "",
|
|
}) as Cell[][];
|
|
}
|
|
|
|
/** A cell as trimmed text. Absent, null and blank all collapse to "". */
|
|
function text(cell: Cell): string {
|
|
return String(cell ?? "").trim();
|
|
}
|
|
|
|
/** True when every cell in the row is blank — a spacer row, not data. */
|
|
function isBlankRow(row: Cell[]): boolean {
|
|
return row.every((cell) => text(cell) === "");
|
|
}
|
|
|
|
/**
|
|
* Turn raw sheet cells into headers plus rows, or a friendly error.
|
|
*
|
|
* Exported separately from `parseImportFile` so tests (and any future caller
|
|
* that already holds cells) can exercise the row logic without going through
|
|
* SheetJS.
|
|
*/
|
|
export function parseImportSheet(rows: Cell[][]): ParseImportResult {
|
|
// Spreadsheets exported from asset-management systems often carry a blank
|
|
// lead-in row or two, so find the first row with content rather than
|
|
// assuming index 0.
|
|
const headerIndex = rows.findIndex((row) => !isBlankRow(row));
|
|
if (headerIndex === -1) {
|
|
return {
|
|
ok: false,
|
|
error: "That file is empty — it needs a header row and at least one row of data.",
|
|
};
|
|
}
|
|
|
|
// Trailing blank columns are an artefact of how spreadsheets size their used
|
|
// range; drop them so the header count reflects real columns.
|
|
const rawHeaders = rows[headerIndex].map(text);
|
|
let lastUsed = rawHeaders.length - 1;
|
|
while (lastUsed >= 0 && rawHeaders[lastUsed] === "") lastUsed--;
|
|
const headers = rawHeaders.slice(0, lastUsed + 1);
|
|
|
|
if (headers.length === 0) {
|
|
return {
|
|
ok: false,
|
|
error: "Couldn't find a header row — the first row with content is blank.",
|
|
};
|
|
}
|
|
|
|
// A gap *between* named columns would silently misalign the mapping step.
|
|
const blankAt = headers.indexOf("");
|
|
if (blankAt !== -1) {
|
|
return {
|
|
ok: false,
|
|
error: `Column ${blankAt + 1} has no heading — give every column a heading, or remove the empty column.`,
|
|
};
|
|
}
|
|
|
|
// Two identically-named columns make "which column did I map?" unanswerable.
|
|
const duplicate = findDuplicateHeader(headers);
|
|
if (duplicate) {
|
|
return {
|
|
ok: false,
|
|
error: `The file has more than one '${duplicate}' column — give each column a unique heading.`,
|
|
};
|
|
}
|
|
|
|
const dataRows: string[][] = [];
|
|
for (const row of rows.slice(headerIndex + 1)) {
|
|
if (isBlankRow(row)) continue;
|
|
// Pad short rows and truncate long ones so every row lines up with headers.
|
|
dataRows.push(
|
|
Array.from({ length: headers.length }, (_, i) => text(row[i])),
|
|
);
|
|
// Bail as soon as the cap is passed — no point normalising 500k more rows.
|
|
if (dataRows.length > MAX_IMPORT_ROWS) {
|
|
return {
|
|
ok: false,
|
|
error: `The file has more than ${MAX_IMPORT_ROWS.toLocaleString("en-GB")} rows — split it into smaller files.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (dataRows.length === 0) {
|
|
return {
|
|
ok: false,
|
|
error: "The file has a header row but no rows of data.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
file: {
|
|
headers,
|
|
rows: dataRows,
|
|
rowCount: dataRows.length,
|
|
preview: dataRows.slice(0, IMPORT_PREVIEW_ROWS),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The first header that appears twice (case- and whitespace-insensitively). */
|
|
function findDuplicateHeader(headers: string[]): string | null {
|
|
const seen = new Set<string>();
|
|
for (const header of headers) {
|
|
const key = header.toLowerCase().replace(/\s+/g, " ");
|
|
if (seen.has(key)) return header;
|
|
seen.add(key);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Read and parse an uploaded file end to end, turning every failure into a
|
|
* friendly `error` the route handler can return verbatim.
|
|
*
|
|
* Note that SheetJS only *throws* on a structurally broken container — a
|
|
* corrupt or encrypted XLSX zip. Loose bytes (a PDF, an image, random noise)
|
|
* are read happily as a one-line CSV, so they arrive at `parseImportSheet` as a
|
|
* degenerate sheet and are turned away by the header/row checks there rather
|
|
* than here. The extension allowlist on the route handler is what keeps that
|
|
* from being the user's first line of defence.
|
|
*/
|
|
export function parseImportFile(buf: ArrayBuffer | Uint8Array): ParseImportResult {
|
|
let cells: Cell[][];
|
|
try {
|
|
cells = readImportSheet(buf);
|
|
} catch {
|
|
return {
|
|
ok: false,
|
|
error: "Couldn't read that file — is it a valid CSV or Excel file?",
|
|
};
|
|
}
|
|
return parseImportSheet(cells);
|
|
}
|
|
|
|
/** True when the filename carries one of the accepted extensions. */
|
|
export function hasAcceptedImportExtension(filename: string): boolean {
|
|
const lower = filename.toLowerCase();
|
|
return ACCEPTED_IMPORT_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
}
|
|
|
|
/**
|
|
* 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", "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]
|
|
.map((row) => row.map(csvCell).join(","))
|
|
.join("\r\n") + "\r\n"
|
|
);
|
|
}
|
|
|
|
/** Quote a CSV cell only when it needs it. */
|
|
function csvCell(value: string): string {
|
|
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
}
|