priority was one of the "enum values TBC" text columns. It turns out to be a
flag, not a scale — "look at this one first" — so there is no value set left
to agree, and text was modelling a decision nobody needs to make. Now
boolean NOT NULL DEFAULT false: a third "unknown" state has no meaning for a
flag, and the table either renders it or renders nothing.
Deliberately independent of overdue. Overdue is derived from the forecast
date and the stage ladder; priority is stored, because someone marked it. A
flagged work order may be well inside its dates and a late one nobody
flagged is not a priority, so they are separate columns and separate badges.
Read path: selected and grouped in the page query, carried through
RawWorkOrderRow -> WorkOrderRow, rendered as the wireframe's Priority column
between Docs and the row action. Shown only when true; a column of "No"
chips would give the exception the same weight as the rule.
Docs updated where they claimed otherwise: CONTEXT.md gains a Priority entry
and no longer lists the column as untouched, and schema.md no longer groups
it with the TBC-enum text columns.
MIGRATION NOT INCLUDED AND NOT APPLIED. This commit changes the Drizzle
schema only; work_order.priority is still `text` in the shared production
database, so the boolean the code expects is not there yet. Generating and
applying the migration is the repo owner's call. Two notes for whoever does:
the table holds 0 rows today (verified by a read-only SELECT), so the change
is lossless; and Postgres refuses ALTER COLUMN ... TYPE boolean on a text
column without a USING clause, which drizzle-kit will not emit.
setup/workstreams/page.tsx imported requireProjectAccess from
../../../authz, the stub #406 created and #409 deleted when it moved the
guard into src/app/projects/guards.ts. #410 (workstreams) was built in
parallel against the stub path and merged after #409, so the two landed
cleanly by git yet left a dangling import — the only tsc error in the
repo and a next build failure.
The page calls requireProjectAccess purely for its redirect/notFound
side effect and discards the return, so repointing the import is the
whole fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/projects/[projectId]/work-orders — TanStack Table v8 over the shadcn Table,
columns factored like your-projects/live/PropertyTableColumns.tsx: WO ref,
asset ref, address, workstream, contractor, stage, forecast end, docs, and a
row action to the work-order detail.
The server owns filtering, ordering and paging; the client owns only the
question being asked. A Server Component renders the filter choices and the
unfiltered first page through the same loadWorkOrderPage the route handler
uses, and seeds TanStack Query v4 with it (keepPreviousData, not v5's
placeholderData), so there is no empty flash and no second implementation of
the first paint. No polling, so no refetchInterval signature involved.
Cursor stack rather than a page number: keyset pagination cannot derive
"previous", so the client remembers the cursors it used. Changing a filter
clears the stack — a cursor is a position in one filtered set.
Nothing in the UI decides anything. overdue / progress / evidence counts
arrive already derived by @/lib/projects/derivations; the stage badge takes
its name live from the stage FK and its colour from the derived progress, so
a rename propagates and a project that renames "Completed" keeps the
completed treatment. Docs badges are advisory-only per CONTEXT.md.
Forecast end renders through toLocaleDateString("en-GB") off a local-midnight
Date built by isoToLocalDate — dd/mm/yyyy, per CLAUDE.md and ADR-0019, with
no UTC-parse day shift.
Contractors get a note rather than a 404: they can see the project, so the
lie would be worse than the gap. Their scoped view is a later issue.
Sloping-ceiling roofs (source EPC field PitchedWithSlopingCeiling) were being
flattened onto the horizontal-loft ladder ("Pitched, N mm loft insulation"),
which mis-models a roof whose insulation sits at the rafter/slope line. Add a
dedicated sloping-ceiling family to RoofTypeValues — an "as built" variant
(insulation deferred to construction age band) plus a 12 mm … 400+ mm depth
ladder mirroring the loft one — and the matching ALTER TYPE roof_type ADD VALUE
migration (0278).
property_overrides.override_value is text, so no change there; only the
landlord_roof_type_overrides.value pgEnum is constrained.
Backend counterpart (Model#1676): the roof overlay must model these at rafter
level, and the ColumnClassifier must map PitchedWithSlopingCeiling:<depth|As
Built|Unknown> to the new members. Once live, the affected property_overrides
snapshots + portfolio vocab rows get backfilled.
Note: numbered 0278 off current HEAD; if another in-flight migration also lands
0278, this needs a regenerate/renumber at merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/projects/[projectId]/work-orders serves one page of the admin
work-orders table: workstream / contractor / stage / overdue-only /
missing-required-evidence filters, keyset-paginated by work_order.id so a
cursor stays meaningful under any filter combination.
The rules stay in one place. Every derived filter is split at its AND, the
way #418's dashboard repository splits it, and the TypeScript half crosses
into SQL as data rather than as a re-implemented predicate:
- overdue: buildStageLadders classifies the project's stages in JS and
stageIdsByProgress("complete") hands the terminal ids to the query;
SQL only counts forecast_end < asOf.
- missing evidence: requiredEvidenceRequirementIds decides which
requirement applies at which stage and hands over an explicit
(stage_id, requirement_id) list.
Row badges come from toWorkOrderRow, which calls isOverdue /
progressOfWorkOrder / evidenceCompleteness directly, so the table and the
dashboard reconcile by construction.
Authz is canManageProject (internal + client-org). A contractor gets 403
rather than the whole programme; row scoping is a later issue.
Tests mock @/app/db/db and open no connection.
unscopedRows seeded itself with the blank-workstream row count, so the
contractor table's note told the user those rows were "waiting on their
workstream value — map it above and their contractors appear here". They never
would: a blank cell has no value to map, and blankRows.workstream already
reports them. unscopedRows now counts only rows carrying a workstream value that
is not yet mapped — the rows the note's promise is actually true of.
Adds the counting invariant as a test (every row lands in exactly one workstream
entry or in blankRows.workstream), which is the AC's "value-mapping counts match
the file".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step 2 of 3's screen, on top of the pure logic in the previous commit. Follows
wireframes 08 (value mapping locked) and 09 (unlocked). Writes nothing.
Where the step lives
Client state inside ImportUpload, not a /import/mapping route: the parsed rows
live in that component (#414), so a URL step would drop them and force a
re-upload on every Back. "Continue to mapping" — disabled since #414 —
now opens the mapping screen.
ImportMapping — the screen
Owns the two mapping layers and nothing else. Statuses, counts, which values
need review and whether Continue is enabled are all derived inline from
(file, columnMapping, valueMapping, targets) on each render, so re-pointing the
workstream column immediately re-groups the contractor and stage tables — they
were never a separate copy of the answer. No useEffect, no useMemo.
ColumnMappingTable — file column -> Ara field
One row per file column with the wireframe's matched / needs-review
affordances, an "e.g. …" sample from the preview, and a shadcn Select per row
offering every field plus "Don't import this column". Picking a field another
column already holds says so in the option hint rather than moving it
silently. Beneath the table, the requirement blockers: which required fields
are still unmapped and why. Unmapped *optional* fields are stated, never
blocking.
ValueMappingTables — values -> the project's own entities
Three tables. Workstream values map flat; contractor and stage values are
grouped under the workstream they are scoped to, because a contractor
assignment and a stage ladder belong to a workstream (ADR-0019) — the same
"Acme" under two workstreams is two questions, and each picker offers only
that workstream's assignments. Row counts per value, auto-matched marked as
such. Notes carry the cases with no row of their own: rows waiting on their
workstream mapping, blank contractors (an error downstream), and blank stages
(not an error — they start at the workstream's first stage).
Gating (AC)
The value tables are locked, exactly as wireframe 08 shows, until every
required column is mapped — there is nothing to list until we know which
column holds the workstream. Continue to validation is disabled until the same
condition holds, and names what is missing. Unmapped *values* never block: they
surface as "N of M rows will fail validation as mapped".
page.tsx
Loads the value-mapping targets server-side alongside readiness (read-only, and
skipped entirely for a gated project, which has nothing to map to). Container
widened to max-w-5xl for the mapping tables; the eyebrow now covers steps 1–2.
ADR-0020 — records that the import session, and the mapping persisted alongside
it, are client-held. Persisting the mapping without the rows buys nothing: the
rows are client-held too, so a refresh loses them either way and a restored
mapping has no file to apply to. ImportSession is JSON-clean so a future
server-side session can store it unchanged; the ADR names moving the file
server-side as the thing that would change the answer. It also records the
production-database constraint honestly, as a constraint and not as the reason.
Stepping back to the drop zone and forward again keeps the mapping: the session
is held above the mapping component, so that component may unmount freely.
225 tests pass across src/lib/projects; tsc and eslint clean. No component tests
— this repo runs vitest in a node environment with no DOM setup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full room-in-roof geometry now lives in the epc_room_in_roof /
epc_room_in_roof_surface child tables (#443 / Model #1665). The two flattened
columns on epc_building_part -- room_in_roof_floor_area and
room_in_roof_construction_age_band -- are redundant duplicates of
epc_room_in_roof.floor_area / construction_age_band and are no longer read or
written by the Model backend. No TS code in this repo references them either.
No data migration: the values already live in epc_room_in_roof for re-ingested
properties.
ALTER TABLE epc_building_part DROP COLUMN room_in_roof_floor_area;
ALTER TABLE epc_building_part DROP COLUMN room_in_roof_construction_age_band;
⚠️ Deploy ordering (destructive): apply this ONLY after the Model backend that
no longer writes these columns is deployed to every environment. Dropping them
while a backend that still writes them is live breaks every EPC save.
Refs Hestia-Homes/Model#1665
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pure half of import step 2 of 3, built on #414's parsed-rows-held-client-side
model. No database writes, no migrations; four new modules under
src/lib/projects/import, all pure except one read-only loader.
similarity.ts — the auto-match primitive
Normalisation plus a Sørensen–Dice bigram score, with word containment ranked
above letter overlap ("Contractor" ⊂ "Contractor Name" beats "Stage"/"Storage").
Headings and cell values match by *different* rules: valueSimilarity adds
directional, length-weighted word coverage so "Window Programme" reaches
"Windows" (0.91) and "Door Replacement" reaches "Doors", at a higher threshold
(0.75 vs 0.62) so "Acme Windows Ltd" does not reach "Acme Doors Ltd" (0.67).
The leniency is deliberately kept out of heading matching, where it would map
"Forecast Start" onto "Forecast end date" and mis-read every row in the file.
columnMapping.ts — file column -> Ara field
Seven fields (landlord property id, UPRN, workstream, contractor, stage, WO
reference, forecast end date) with alias lists drawn from real asset-management
exports. Requirements are modelled as groups: property identifier is satisfied
by landlord property id *or* UPRN, so a file may carry either or both.
autoMatchColumns assigns greedily — one column per field, best pair first, and
no guess below the threshold. Two deliberate non-matches: a Status column is
not the Stage field (CONTEXT.md keeps them apart), and nothing matches Priority
or cost (v1 ignores them; #426 owns priority). Per-column status is
matched / ignored / needs_review, where "ignored" is the user's explicit
"don't import" and is distinct from not having looked yet.
valueMapping.ts — cell values -> the project's own entities
Contractor and stage values are keyed by (resolved workstream, value), never by
value alone: "Acme" under Windows is a different assignment from "Acme" under
Doors and may not exist under one of them (ADR-0019). Two file spellings of one
workstream merge into a single contractor question. Rows whose workstream is
blank or unmapped have no scope, so their contractor/stage values are counted
(unscopedRows) rather than guessed, and appear the moment the workstream is
mapped. Value matching is many-to-one (several spellings, one workstream) where
column matching is one-to-one, and a tie between two equally-good candidates is
refused rather than guessed. A user's answer that the workstream no longer
offers lapses to needs-review instead of persisting as a dangling id.
warningRows is counted per row by a second pass, not by summing the tables — a
row with an unmapped workstream and a blank contractor is one broken row.
mappingTargets.ts — what values may map to
Pure assembleMappingTargets + one read-only loadMappingTargets (three SELECTs).
Every bigserial id is stringified once, here, because bigint cannot cross the
server->client boundary. Stages keep ladder order (stages[0] is the blank-stage
fallback at commit); workstreams and contractors sort alphabetically.
session.ts — the artefact the mapping is persisted alongside
ImportSession carries the file identity plus both mapping layers, JSON-clean
end to end. sessionMatchesFile refuses a session whose headers have moved,
since a mapping is column indices.
Blocking rule (AC): only unmapped *required* columns block progression to
validation. Unmapped values are warnings whose rows fail at #417; a blank stage
is not a warning at all (it falls back to the workstream's first stage).
112 tests pass in src/lib/projects/import (66 new); tsc clean. Tests mock
@/app/db/db and open no connection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every unselected card was handed the same NO_DEPENDENTS object; a caller
mutating one would have moved them all. Spread it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Card grid of the seeded workstreams at
/projects/[projectId]/setup/workstreams, rendered from the workstream
reference table (#407) — never a hard-coded list.
The screen is revisitable, not one-shot: every toggle persists straight to
project_workstream, and the current selection is read back from the DB on
each visit. `?from=hub` is the only difference between the first-run
wizard and a later edit from the setup hub (#444) — it swaps Continue for
a return to the hub.
The server decides deselects. The grid always tries the plain DELETE and
reacts to the 409: `requiresConfirmation` raises the confirm dialog,
`blocked` (work orders exist) refuses. The dependent counts on the cards
signpost only; they never gate.
canManage comes from @/lib/projects/authz, so a contractor sees the setup
read-only rather than a broken editor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds the page behind two links that previously 404'd — the per-project
nav's Settings item and the import screen's "finish setup first" CTA.
ADR-0019 made work-order import setup-gated, which turned setup from a
one-shot wizard into something a project accretes over time. This is the
return path: setup status, the four /setup/* steps as revisitable links,
and the editable project details, in that order — the CTA's sender needs
the readiness answer first, so it is the first band on the page.
The setup-status panel renders `loadImportReadiness` verbatim. It restates
no part of the gate: `readiness.ready` is the import screen's own verdict,
and `setupSteps.ts` only rearranges that value for display, so "ready to
import" cannot mean two different things in two places. A test walks the
readiness space and asserts every gated step is done exactly when the
helper says ready. Evidence requirements are badged Optional, never
outstanding — ADR-0019 leaves them out of the gate.
Settings is a management surface, so it is `canManageProject`, not the
layout's view guard: a contractor with legitimate access to the project
404s here rather than seeing a form they may not submit.
The four `/setup/*` hrefs live in one exported list. Those routes are
#410-413, in flight — the hub is what links into all of them, so a slug
that lands differently is one line to correct, not four scattered literals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The settings hub (#444) needs a write path for the project-details form.
This is that path, and nothing else — the hub page itself follows.
`updateProjectSchema` is *derived* from #409's `createProjectSchema` rather
than restated: same fields, same messages, same date rule (now a shared
exported predicate, so the two cannot drift). The one difference is
deliberate — the owning organisation is not editable. Every role on a
project is derived from `project.organisation_id`, so re-homing one is a
re-permissioning act, not a detail edit; it is absent from the schema and
absent from the update statement.
Authorization asks the existing rule twice. First, may this user manage
this project (404 if they cannot even view it, so existence stays private;
an honest 403 for a contractor who already knows it exists). Second, would
they still manage it *afterwards* — un-ticking `domna_admin_access` revokes
the internal role, so an internal non-member could otherwise save an edit
that locks them out with no way back. That is the same guard POST
/api/projects applies to creation, via the mirror of its
`prospectiveProjectFacts`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds POST/DELETE/GET under /api/projects/[projectId]/workstreams so the
setup screen can persist its selection as project_workstream rows.
The deselect cascade rules live in a pure `cascade.ts` — work orders block
a deselect outright; configured stages, evidence requirements and
contractor assignments warn and require `?confirm=true`, then cascade.
Keeping them pure lets the browser import the same wording and the rules
unit-test without a connection.
Authorization is wired, never re-implemented: facts from the #408 authz
repository, decisions from @/lib/projects/authz. A project the caller
cannot see reports 404, not 403, matching the route-shell guard.
Dependent counts are one round trip with count(distinct) over four left
joins; a Work order is reached by both its stage and its contractor FK, so
either path blocks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve the dashboard page against #439's authz -> guards move.
- src/app/projects/[projectId]/page.tsx: keep the full #418 dashboard;
import requireProjectAccess from ../guards (../authz was deleted by #439).
Semantic swap, not just a path change: guards' requireProjectAccess now
returns { user, project } and enforces id-parse + view access internally,
so the page reads the guaranteed-valid project.id back from it and drops
its own parseProjectId import/usage (which also clears the parseProjectId
name-clash with guards').
Also fix pre-existing breakage the #439/#440 merges left on main, so the
branch typechecks (agreed with the author before committing):
- src/app/projects/[projectId]/import/page.tsx (#414/#440): still imported
requireProjectAccess from the deleted ../../authz. Repointed to ../../guards
and adapted to its { user, project } return, layering canManageProject on
top of the guard's view check (import stays manager-only). Removed the now
unused loadAuthzUser/loadProjectAuthzFacts imports and manual id parse.
- react-day-picker: the shared node_modules had 8.10.1 while main's
package.json/lock require ^10.0.1 (calendar.tsx/DateInput.tsx use the v10
API). Installed 10.0.1 into the shared node_modules only; no package.json
or lockfile change here.
Verified: tsc --noEmit clean; vitest 793 passed / 0 failed (no DB connection;
repository test keeps its vi.mock of @/app/db/db). Cypress not run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Model backend reads several gov-EPC fields the calculator uses, but our
Postgres schema had no column for them, so they were dropped on save. A
dwelling reloaded from the DB lost them and was re-scored on pessimistic
defaults -- worst cases -15.5 SAP. This is the FE half of Model #1665 and the
deploy gate for the backend change.
One migration, additive only:
epc_property +1 col: heating_cylinder_heat_loss (double precision, null)
epc_building_part +7 cols:
alt_wall_{1,2}_{u_value, thickness_mm, is_basement}
wall_u_value
epc_roof_window (new) 0..n per epc_property
epc_room_in_roof (new) 0..1 per epc_building_part
epc_room_in_roof_surface (new) 0..n per epc_room_in_roof
wall_u_value is the lodged gov-EPC wall U-value. #1661 dropped the lodged
wall/roof/floor U-values as full-SAP artefacts because the re-model derived
U-values from the construction-default cascade; #1665 reverses that for
wall_u_value specifically -- the calculator now honours the lodged wall U over
the derived default. Nullable: null means "not lodged → the calculator derives
it". roof_u_value / floor_u_value stay unpersisted.
Nullability is load-bearing, not incidental:
- alt_wall_{1,2}_is_basement are nullable, NOT notNull().default(false).
null "not stated" vs false "explicitly not a basement" are distinct;
collapsing them re-enables the code-6 basement heuristic the flag defeats.
- room_in_roof_surface.insulation_thickness_mm nullable -- null vs 0 is the
U-value branch (Table 17 vs default).
- room_in_roof_surface.u_value nullable -- assessor override, null != 0.
- room_in_roof_surface.area_m2 has no CHECK >= 0: it can be negative (signed
RdSAP 3.9.2 adjustment).
- surface.kind is TEXT, not a pg enum -- the value set has grown twice.
Child FKs use no explicit onDelete, matching every existing EPC child table
(epc_photovoltaic_array, epc_window, epc_floor_dimension, …): the Python
backend owns these tables and rewrites children by delete-and-reinsert on
re-ingest; the app-side deleteProperty path does not touch epc_property or its
children.
Supersession without a drop: epc_room_in_roof supersedes the inlined
epc_building_part.room_in_roof_floor_area / room_in_roof_construction_age_band,
but those are left in place. They are dropped in a follow-up migration after the
backend deploys and the round-trip is verified -- dropping them here would make
the deploy non-reversible.
Deploy ordering: this migration must be applied to every environment BEFORE the
backend deploys. The backend SELECTs these columns explicitly, so if code ships
first every EPC read raises UndefinedColumn/UndefinedTable -- a hard failure on
all property loads. And because the columns never existed, existing rows have
nothing to restore: affected properties (~11% with a room-in-roof, plus the
roof-window / cylinder / alt-wall / wall-U cohorts) must be re-ingested from the
gov-EPC API before their scores move.
Refs Hestia-Homes/Model#1665
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Paging from July to August 2026 moved the popover from below the field to
above it, and September moved it back down. The cause is the month grid's
height: a month needs five or six week rows depending on the weekday it starts
on — August 2026 starts on a Saturday and needs six, July and September need
five — and Radix re-runs collision detection when popover content resizes, so
one extra row is enough to flip the side.
`fixedWeeks` renders six rows for every month, filling the spare row from the
neighbouring month, so the height is constant and there is nothing to react
to. It is a default rather than a hardcode, and the reasoning is in the
component's doc comment so it is not later tidied away as a stylistic choice.
Collision detection is deliberately left on. A field genuinely near the bottom
of the viewport should still open upward; disabling it would trade a jump for
a calendar clipped off-screen. What is fixed is the jumping, not the flipping.
TESTS: tsc --noEmit and ESLint clean; vitest 582 passing. Unverified in a
browser for the same reasons as the two preceding commits — no jsdom or
testing-library here, and `next build` would render against a production
DATABASE_URL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dropping `<input type="date">` for the dd/mm/yyyy mask also dropped its
calendar, leaving typing as the only way to enter a date. ADR-0019 accepted
that cost but named the remedy: add a picker *behind* `DateInput`, writing the
same ISO value, rather than revert to the native control. That was felt
immediately in use, so this does it.
`DateInput` now renders a calendar button inside the field's right edge and a
Calendar in a Radix popover. Typing and picking are equal routes to the same
value — picking fills the text, typing moves the grid — so neither is
privileged and the two cannot disagree. The trigger is `type="button"` so it
cannot submit the form, and carries an aria-label, a bare icon having no
accessible name.
The calendar wrapper is written by hand rather than taken from the shadcn
registry: the published template targets react-day-picker v8/v9, whose
`classNames` keys differ from the `UI` enum v10 uses. It defaults to the enGB
locale, which carries `weekStartsOn: 1`, so the grid starts on Monday without
being told to.
The delicate part is the `Date` boundary. A calendar works in `Date`s, and
both obvious conversions are wrong: `new Date("2026-03-01")` is midnight UTC,
which is 29 February west of Greenwich, and `toISOString()` shifts the day back
the other way. So `isoToLocalDate` and `localDateToIso` build and read local
parts, and they are the only two functions in `src/utils/dates.ts` that touch
`Date` at all — everything else stays string-only. The round trip is tested
across a full month rather than at a sample day, because the failure only
appears on some days in some zones; the suite was also run under
TZ=America/Los_Angeles and TZ=Pacific/Auckland.
Dependencies: react-day-picker and date-fns, the latter already present
transitively and now direct at v4. `@tremor/react` bundles react-day-picker v8
nested and npm kept it isolated on date-fns v3, so nothing existing changed
version; no Tremor DatePicker is used anywhere in src, so there is no second
copy in the bundle. The alternative was a hand-rolled month grid with no
dependency, rejected because keyboard navigation and ARIA on a calendar are
easy to get subtly wrong and not worth owning.
ADR-0019's "the native calendar picker is gone" consequence was now false and
has been rewritten.
TESTS: tsc --noEmit and ESLint clean; vitest 582 passing (53 files), 24 of them
for the date helpers. NOT verified in a browser — this project has no jsdom or
testing-library, and `next build` was not run because it would statically
render pages against a DATABASE_URL still pointing at production. The new
Cypress case, which picks 17 March from the grid and expects 17/03/2026 in the
field, is unrun like the rest of that spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create-project form's date fields were `<input type="date">`, whose
display format follows the *browser's* locale rather than the document's. On a
US-configured browser they rendered mm/dd/yyyy inside an app that renders
en-GB everywhere else (~40 call sites). That is not cosmetic: 03/09/2026 is a
valid date under both readings, so a user cannot tell which one the box took
and no validation can catch it. The failure mode is silently wrong data, not
an error message. No attribute, `lang` value or CSS overrides the native
control, so the control had to go.
`DateInput` replaces it: a text field that masks to dd/mm/yyyy as you type,
whose value and onChange remain YYYY-MM-DD. The display format therefore stops
at the input's edge — the schema, the request body and the Drizzle `date`
column are untouched, and dates keep sorting lexicographically with no
timezone in play. The cost is the native calendar picker, which ADR-0019
argues is the right way round: a picker is a convenience, an ambiguous entry
format is a correctness problem. If one is wanted later it belongs behind
`DateInput`, not in a revert to the native control.
The parsing lives in `src/utils/dates.ts`, pure and Date-free — `new
Date("2026-03-01")` is midnight UTC, which is the previous day in a
negative-offset zone, so this compares and builds strings instead. Impossible
days are rejected explicitly because `Date` would roll 31/02 forward into
March rather than refuse it.
Two behaviours worth knowing, both covered by tests. A *finished* but
impossible date is handed to the schema as typed rather than reported as
empty: a field visibly holding 31/02/2026 while an optional-date schema reads
it as unfilled would tell the user nothing. Unfinished input does report
empty, so no error fires mid-keystroke. And the mask only re-adds a separator
while characters are being added — doing it during a backspace would make the
slash undeletable.
`DateInput` adjusts state during render rather than in a `useEffect` (the
documented alternative) to resync when the form sets the field from outside.
Per CLAUDE.md's utils split, dates.ts is generic and lives in the new
`src/utils/`; `src/app/utils.ts` stays for company-specific helpers (EPC
bands, SAP points) and `src/lib/utils.ts` is vendored Tremor/shadcn.
CLAUDE.md records the rule and the "never `<input type=\"date\">`"
prohibition so the next agent finds it before writing the wrong thing.
Five other date inputs remain wrong under this ADR — PibiSection,
PibiDatesEditor and PropertyFilters — left alone as unrelated to this branch
and recorded in ADR-0019 as convert-when-next-touched.
TESTS: tsc --noEmit and ESLint clean; vitest 576 passing (53 files), 18 of
them new for the date helpers. Cypress was NOT run — this environment's
DATABASE_URL still points at production. The spec's four date entries were
converted to day-first, an assertion added that 01/03/2026 crosses the wire as
2026-03-01, and a new case covers the mask and the impossible-date refusal;
all three are unverified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UNASSIGNED_KEY was written as "\x00unassigned" — the leading NUL was an
editing artifact, and it forced git to store dashboardSummary.ts as a
binary blob. That file is the fold #419 and #422 are meant to read, so it
needs to render as a reviewable text diff.
The key is an internal map bucket for work orders with no contractor
(contractorOrganisationId is a numeric-id string, never the literal
"unassigned"), so dropping the NUL changes no behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Model repo reads both fields from the gov-EPC API but neither had a
column here, so both were mapped and then silently discarded on save --
inert for any dwelling read back from Postgres.
rafter_insulation_thickness: property 749719 lodges "250mm", which bills
the roof on RdSAP 10 Table 16 col (2) at U 0.23. Dropped on the DB
round-trip it falls to the Table 18 col (2) unknown-thickness default of
2.30 -- the spec's *uninsulated* value, a 10x error:
quantity | lodged | dropped | with field
SAP | 73 (band C) | 63.22 | 72.65
primary energy kWh/m2/yr | 212 | 327.9 | 212.3
CO2 t/yr | 1.5 | 2.40 | 1.54
It is jsonb, not text, to match the sibling thickness columns: the value
is Union[str, int] -- "250mm" from the gov API, int mm from Site Notes --
and the Python round-trip test asserts int-vs-str survives exactly.
wall_is_basement: not a lodged U-value -- it selects WHICH RdSAP default
applies (RdSAP 10 5.17 / Table 23). Nullable is load-bearing, so it is
deliberately NOT notNull().default(false). Three states are distinct:
null "not stated" falls back to the gov-API code-6 heuristic, false
"explicitly system-built" suppresses it, true is a basement wall. Code 6
is canonically system-built and the Elmhurst site-notes mapper sets false
precisely to defeat the heuristic, so collapsing false to null INVERTS
the determination -- a system-built wall bills as a basement wall and,
via has_basement, drags the whole ground floor onto the Table 23
basement-floor U-value. Rare (54 of 67k building parts in the 2026
sweep) but silent and directional.
Deliberately NOT persisted: wall_u_value, roof_u_value, floor_u_value.
Those are full-SAP artefacts. We re-model every dwelling *as* an RdSAP
assessment, which derives U-values from the construction-default cascade
rather than honouring an assessor's measured value, so persisting them
would make the re-model a hybrid. Corpus check across 1,000
RdSAP-21.0.1 certs: roof_u_value 0, floor_u_value 0, wall_u_value
4 (0.4%).
Both columns nullable, additive, no backfill -- neither ever existed, so
there is nothing to restore. Affected properties must be re-ingested from
the gov-EPC API before their scores move.
Deploy ordering: this migration must land BEFORE the Model-side backend
deploys. The Python SQLModel now selects both columns, so without them
every EPC read fails outright with "column
epc_building_part.rafter_insulation_thickness does not exist" -- not a
graceful degradation.
Refs Hestia-Homes/Model#1661
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements #418: `/projects/[projectId]` as a Server Component over SQL
aggregates, plus the shared derivation helpers that #419 and #422 are
instructed to reuse rather than reimplement.
## The shared interface — `src/lib/projects/derivations.ts`
Pure, no db client, no table shapes; unit-tested against in-memory
fixtures. This is the single source of truth for *complete*, *overdue*
and *evidence completeness*. Code against it sight-unseen:
buildStageLadders(stages: StageLadderEntry[]): StageLadders
Resolve the ladder once, then ask it questions. `StageLadderEntry` is
`{ id, projectWorkstreamId, order }` — one `project_workstream_stage`
row. Pass EVERY stage of every workstream you will ask about; a partial
ladder derives the wrong terminal stage.
Row-level, over `WorkOrderDerivationInput`
(`{ projectWorkstreamStageId, forecastEnd: string | Date | null }`):
progressOfWorkOrder(ladders, wo): "not_started" | "in_progress" | "complete" | null
isComplete(ladders, wo): boolean
isOverdue(ladders, wo, asOf: Date): boolean
Set-level, for pushing into SQL instead of streaming rows into JS:
ladders.stageIdsByProgress(progress): bigint[]
ladders.isTerminalStage(stageId): boolean
ladders.orderOfStage(stageId) / ladders.workstreamOfStage(stageId)
Evidence (advisory counts only — never a gate, per CONTEXT.md):
requiredEvidenceRequirementIds(ladders, requirements, stageId): bigint[]
evidenceCompleteness(required, satisfied): EvidenceCompleteness
sumEvidenceCompleteness(parts): EvidenceCompleteness
isMissingRequiredEvidence(completeness): boolean
`EvidenceCompleteness` is `{ required, satisfied, missing, ratio }` where
`ratio` is `null` — not `0` — when nothing is required, so "unconfigured"
and "0% met" stay distinguishable. `sumEvidenceCompleteness` sums counts
before dividing rather than averaging percentages.
Also exported: `toCalendarDay(value)`, which normalises a `date` column
value to `YYYY-MM-DD` UTC. Bind it as a SQL parameter rather than using
`CURRENT_DATE`, so a page and its queries measure against the same day
across midnight.
### Rules, as decided in the issue
- complete = stage is the ladder's terminal stage (highest `order`)
- overdue = `forecast_end` strictly before today AND not complete
- evidence = advisory counts of submissions against required requirements
### Edge cases pinned down (documented because two tickets depend on them)
- Single-stage ladder is terminal, not first: it reads `complete`.
- Every stage tied at the maximum `order` is terminal (and at the minimum,
first) — `order` is not unique in the schema, so this stays deterministic.
- Unknown/unloaded stage id derives `null` progress: not complete, and
still eligible to be overdue.
- Dates compare as calendar days in UTC. Due *today* is not overdue.
- A stage-scoped requirement applies once the work order has REACHED that
stage (`requirement order <= work order order`); unscoped applies always.
Only `required = true` requirements count.
## Aggregating in SQL without duplicating the rules
`src/app/repositories/projects/dashboardRepository.ts` counts in the
database — one grouped query over (workstream, contractor, stage) returns
a few hundred rows regardless of the work-order count. The rules are not
restated in SQL:
- Terminal-ness is never asked of SQL. The aggregate groups by stage id;
`buildStageLadders` classifies those few dozen ids in JS.
- Overdue is split at its `AND`: SQL counts rows past their forecast day
(`pastForecast`), and `overdueOf` drops terminal groups.
- Evidence applicability is decided by `requiredEvidenceRequirementIds`
and handed to SQL as a `(stage_id, requirement_id)` VALUES list — it
crosses into SQL as data, never as a re-implemented predicate.
`src/lib/projects/dashboardSummary.ts` folds those grouped rows into the
four bands, so the KPI totals and the tables beneath them are derived from
the same aggregate and cannot disagree.
## Notes
- "Unassigned contractor" is structurally unreachable in v1
(`work_order.project_workstream_contractor_id` is NOT NULL), so the
count reads 0. It is computed via a LEFT JOIN rather than hard-coded so
it becomes truthful on its own if that column is ever relaxed.
- Authorization is left on the existing `../authz` route seam, which the
per-project layout also applies. Replacing it with `@/lib/projects/authz`
is #409's work; this change adds no permission logic of its own.
- "Awaiting auth", "No access" and notifications are cut from the
wireframe, as the issue specifies — no schema support.
## Seeding: written, NOT run
`src/app/db/seed/seed-projects-dashboard.ts` seeds ~1,500 work orders so a
human can check the render at programme scale. **It was deliberately never
executed.** The database reachable from this environment is the shared
PRODUCTION database, so seeding from here would have written fabricated
delivery records into live data. Run it yourself against a non-production
database:
ALLOW_PROJECTS_DASHBOARD_SEED=i-am-not-production \
npx tsx src/app/db/seed/seed-projects-dashboard.ts <projectId>
It refuses to run without that variable, refuses under NODE_ENV=production,
and prints DB_HOST with a 5s pause before writing. Everything it inserts is
prefixed `SEED-418-` so it can be removed again.
Consequently the "renders acceptably with ~1,500 seeded orders" criterion
is UNVERIFIED — it needs that script run on a scratch database and the page
opened. The query shape is designed for it (grouped counts, bounded result
set), but designed-for is not measured.
## Tests
110 tests over the derivation logic, the fold and the query construction —
in-memory fixtures throughout. The repository test stubs `@/app/db/db` via
`vi.mock` so no `pg.Pool` is ever constructed and no connection can open.
Full suite: 618 passed. Typecheck and lint clean. Cypress not run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Setup wizard step 1/5 (#409).
The list is a Server Component, so the visibility rule runs before anything
reaches the browser and a project the user may not see is never serialised
to it. The rule itself is not restated: `listVisibleProjects` loads each
project's facts and asks `canViewProject` from `@/lib/projects/authz`.
Writing the `domna_admin_access OR owning-org OR contractor-org`
disjunction a second time in SQL would have given it two definitions free
to drift, and the acceptance criteria (client sees its own, contractor sees
what it is assigned to, internal sees all subject to `domna_admin_access`)
fall out of the one definition instead of being re-derived.
The cost is filtering in the app rather than the database, over two round
trips and no N+1. That is the right trade while a project is a works
programme and they number in the tens; if the table grows, the fix is a
superset prefilter that still lets `canViewProject` decide, not a WHERE
clause that re-implements it. Flagged in the function's own docs.
Creation reuses the same guard rather than inventing a "can create" rule:
`prospectiveProjectFacts` builds the facts the project *would* have, and
`canManageProject` judges them. One consequence is worth knowing, and is
covered by a test — `domna_admin_access` gates the `internal` role, so a
Domna user creating for an organisation they are not a member of must grant
that access. Without it they would be creating a project they could not
then see, and the guard refuses rather than producing an orphan. The modal
offers only organisations the user may pick, but the route handler re-checks
the submitted one: a hidden option is not a permission.
`createProjectSchema` is shared by the form (as a zodResolver) and the route
handler (as the body parser), so the two cannot disagree about what a valid
draft is. Empty strings from untouched inputs normalise to undefined rather
than reaching the columns as "".
Also updates projects-shell.cy.js. Its "renders the per-project dashboard
shell" test passed only because the authz stub could never return false;
with the real lib wired in, /projects/<id> is a 404 without standing on
that project, so the test now asserts that instead.
TESTS: the vitest unit tests pass (50, no database connection). Cypress was
NOT run — this environment's DATABASE_URL points at production. The new
spec is therefore unverified, and its end-to-end half, which INSERTs, is
gated behind CYPRESS_ARA_PROJECTS_E2E_WRITES so it cannot fire by accident;
it also needs the #407 seed migration (0274) applied, which has not been run
against any database yet either.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
`src/app/projects/authz.ts` was a placeholder from the route-shell work
(#406): session checks only, with a TODO(#408) to replace its bodies once
the real rules landed. #408 has landed, so it goes.
The real lib (`src/lib/projects/authz.ts`) is deliberately pure — it takes
project facts explicitly and cannot read a session, hit the database, or
redirect. So the four route files do not import it directly; they import a
thin glue layer, `src/app/projects/guards.ts`, which resolves the session
and the facts, delegates every decision to the lib, and turns a refusal
into a Next.js navigation. It holds no permission rules of its own.
Two behaviour notes:
- The redirect-on-no-session behaviour is preserved as-is: no session (or
a session whose email has no `user` row) still redirects to `/`, matching
`src/middleware.ts`.
- Denial of a specific project now calls `notFound()` instead of
redirecting to `/projects`, which is what the stub's own TODO asked for.
It was redirecting only because `canViewProject` could never return
false; now it can, and a 404 is what keeps Project existence from
leaking to users outside its organisation.
`loadAuthzUserByEmail` joins the repository because the session carries no
usable id — `session.user.dbId` is set by the `jwt` callback at initial
sign-in only, so sessions issued before that field existed lack it. Email
is unique on `user`, so it resolves the same row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reorganises the authz lib along DDD lines by separating the persistence
boundary from the domain:
src/lib/projects/authz.ts domain — pure decisions
src/app/repositories/projects/authzRepository.ts persistence — Drizzle
The repository sits alongside src/app/db/ and mirrors the existing
db/schema/projects/ layout, so schema and repository stay symmetrical as
Ara Projects grows more of both.
Dependencies point one way: the repository imports the domain's fact types
and returns them, so the domain never learns a table shape and continues to
import no database client. The 34 guard tests still run with no connection.
No behaviour change — the queries and guards are untouched. Note this is a
new pattern for the codebase: the prevailing convention is a colocated
queries.ts/server.ts per lib folder, which 88 files still follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code review: /onboarding is *user* onboarding, not Portfolio onboarding.
It captures the user's details and their required privacy-policy
acceptance (plus marketing opt-in) and touches no Portfolio at all —
confirmed by the form schema in src/app/onboarding/page.tsx, which has
no portfolio coupling and makes acceptedPrivacy mandatory.
The exemption added in e34f166f was therefore wrong: it let contractor
users into the app without the consent we are required to capture. A
contractor can complete onboarding perfectly well, so nobody needs the
bypass.
Removes the /projects exemption and isProjectsPath(). /projects stays in
the matcher, so the tree is still auth-guarded. Unit and Cypress tests
inverted to assert the corrected behaviour, plus a new case covering an
onboarded contractor reaching /projects.
Refs #406
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds migration 0274, a data-only custom SQL migration seeding the two
reference tables the setup wizard needs (#407):
- project_type: Retrofit, Planned Maintenance
- workstream: the canonical 8 from the UX wireframes, each with a
one-line description (the column is NOT NULL)
Note that #407 specifies the second project type as "New Build"; PR review
superseded that with "Planned Maintenance". The ticket text is stale, not
the code.
Idempotency uses INSERT ... SELECT ... WHERE NOT EXISTS rather than
ON CONFLICT DO NOTHING, because neither table has a unique constraint on
`name` — an ON CONFLICT (name) form would fail at runtime with "no unique
or exclusion constraint matching the ON CONFLICT specification". Re-running
is a no-op and leaves existing rows, including edited descriptions, alone.
The 0274 snapshot is a copy of 0273 with chained id/prevId, matching how
the previous data-only migration (0227) was recorded.
No UI for creating workstreams — deferred to #428.
UNVERIFIED AGAINST A LIVE DATABASE. This environment's DB credentials point
at production, so the migration was deliberately not executed. It was
verified statically only: both statements parse against the PostgreSQL
grammar (libpg_query), the target tables and columns were cross-checked
against the 0273 schema snapshot, every NOT NULL column without a default
is supplied, and the seeded values match the intended set exactly. A human
needs to run it against a non-production database and confirm both the
fresh-DB and re-run cases before it ships.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ara Projects gets its own top-level route tree rather than living under
the /portfolio/[slug] chrome: Projects are organisation-scoped
(project.organisation_id) and contractor users from external orgs will
work in these screens without ever having a Portfolio.
- src/app/projects/layout.tsx: shell + top-level nav
- src/app/projects/page.tsx: list stub (real list lands with #408's
visibility rule)
- src/app/projects/[projectId]/{layout,page}.tsx: per-project nav
(Dashboard, Work orders, Import, Settings) + dashboard shell
- ProjectsNav: the Toolbar.tsx shadcn navigation-menu pattern, taking
its items as a prop so both tiers share one implementation
Middleware: adds /projects/:path* to the matcher and exempts the tree
from the portfolio onboarding redirect. A contractor is never
`onboarded`, so without the exemption they would be bounced to
/onboarding on every request with no way out. The decision is split into
a pure routeAuthenticatedRequest() so the rule is unit-testable without
minting a JWT.
Authorization is a thin seam in src/app/projects/authz.ts with TODOs
referencing #408 — the shell deliberately does not inline permission
logic, and user->organisation resolution does not exist yet either.
Tests: 8 unit tests over the middleware routing rules; Cypress smoke
test for the unauthenticated redirect, the authenticated render, the
contractor path, and the per-project shell.
Refs #406
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds src/lib/projects/authz.ts as the single source of permission truth
for every Ara Projects route handler — no route re-implements this inline.
Ara Projects are organisation-scoped, so none of the portfolio-scoped
team_portfolio_permissions machinery applies. Membership resolves through
team_members -> team -> org_id instead.
Role resolution is most-privileged-first: internal (@domna.homes) >
client (member of the owning org) > contractor (member of an org assigned
to any of the project's workstreams) > no access. project.domna_admin_access
gates the internal role only — with it off, a Domna user falls through to
whatever their own memberships earn them rather than losing access outright.
Client outranks contractor when one org is both owner and deliverer.
The two work-order guards check the permission flag on the *assignment*
rather than on the organisation: a contractor needs membership of that
work order's own assigned org plus the relevant flag, so holding a
permissive assignment on a sibling workstream grants nothing.
Structure follows the repo's model/queries split — authz.ts is pure and
imports no database client, so all 34 guard tests run on in-memory
fixtures with no connection. DB-backed fact loaders (getUserOrganisations
and friends) live in authzQueries.ts.
Note the guard signatures take the project facts explicitly —
canUpdateStage(user, project, workOrder) rather than the (user, workOrder)
in the ticket — since the role can only be resolved against the project.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>