Commit graph

2 commits

Author SHA1 Message Date
Daniel Roth
4204b1fe0e 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>
2026-07-22 16:36:16 +00:00
Daniel Roth
9cfde2767f feat(ara-projects): work-order import step 1 — upload and parse
Adds `/projects/[projectId]/import`: a CSV/XLSX drop zone that uploads to a
route handler, parses server-side with SheetJS (already a dependency — the
Bulk tag assignment flow is the in-app parsing precedent, not the FastAPI
BulkUpload pipeline), and returns headers plus a first-N preview.

Nothing is written to the database at this step. Insert happens at commit
(#417).

Files:
  src/lib/projects/import/parse.ts       pure, DB-free parser + template
  src/lib/projects/import/parse.test.ts  23 unit tests, no DB, no fixtures
  .../api/projects/[projectId]/import/parse/route.ts     POST multipart
  .../api/projects/[projectId]/import/template/route.ts  GET template CSV
  .../projects/[projectId]/import/page.tsx               server component
  .../import/components/ImportUpload.tsx                 drop zone + preview

Persistence choice: hold rows client-side, not S3-and-reparse
-------------------------------------------------------------
The ticket asks for a choice between uploading the raw file to S3 via the
presigned-URL pattern in `src/lib/bulkUpload/client.ts` and re-parsing it per
step, versus holding the parsed rows in the client and POSTing them at commit.

Chosen: hold the rows client-side. The parse route therefore returns every
row, not only the preview, and the mapping (#415) and validation (#416) steps
read them from `ImportUpload`'s mutation state.

Reasoning:

1. S3-and-reparse needs somewhere to keep the object key and the import's
   status between steps, and the Ara Projects schema (PR #404) has no import
   table. Adding one means designing a second BulkUpload-shaped lifecycle —
   hard to reverse, and ADR-worthy — for a v1 that is insert-only with no
   diffing (deferred to #425). Not worth it to carry a file across three steps
   of one sitting.

2. The scale does not call for it. This import declares which workstreams the
   properties of a single project take — thousands of rows, not the
   hundreds-of-thousands the BulkUpload property pipeline handles. The 20k row
   cap bounds the commit POST to a few MB of JSON.

3. Re-parsing per step parses the same bytes three or four times and lets the
   steps disagree if the parser ever changes underneath them. Client-held rows
   are by construction the exact rows the user previewed and will validate.

4. It matches the existing precedent: Bulk tag assignment (ADR-0013) parses a
   small spreadsheet and POSTs the extracted identifiers at commit, with no
   S3 round trip.

The cost is that a page refresh loses progress and the commit POST carries the
payload. Both are acceptable for a single-sitting, insert-only v1; if the
import later needs to be resumable or to exceed this scale, S3 staging plus an
import table is the upgrade path, and the parser is already isolated behind
`parseImportFile` so only the transport changes.

Notes
-----
- The parser is deliberately header-agnostic: it reports the headers it finds
  rather than demanding the template's. Mapping arbitrary client headings is
  #415's job, and rejecting unrecognised headings here would make the mapping
  step unreachable for exactly the files that need it.
- Authorization uses `canManageProject` from `src/lib/projects/authz.ts` with
  facts from the #408 repository, so importing is internal/client-org only and
  never a contractor. Unreachable projects 404 rather than 403. The page
  applies the same check so a contractor never sees the drop zone.
- `raw: false` on the SheetJS read keeps leading zeros on numeric-looking
  property identifiers (covered by a test).
- Size is capped at 10MB client- and server-side. The wireframe says 50MB;
  10MB is far past what four narrow columns can plausibly reach.
- Known limitation, documented in a characterisation test: SheetJS does not
  throw on loose bytes, so a multi-line non-spreadsheet renamed to .csv parses
  into nonsense headers rather than being rejected. Nothing is written, the
  extension allowlist catches the honestly-named case, and the user sees the
  garbage in the preview. Content sniffing here would be guesswork.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:43:48 +00:00