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>
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>
Records that Ara Projects supersedes the HubSpot Live projects feature
long-term (coexisting for now, no v1 migration) and reserves the top-level
/projects namespace. Adds the canonical Ara Projects glossary to CONTEXT.md,
matching src/app/db/schema/projects/schema.md, and qualifies the ambiguous
'Project' references (HubSpot Project vs Ara Project).
Closes#405
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
majorConditionDeals was computed in computeLiveTrackerData and threaded through
LiveTrackerProps → LiveTracker → AnalyticsView, but never rendered — the
intended "Awaab's Law card" was never built. With the rendered "Flagged at
Survey" count now keyed on the same hasMajorConditionIssue signal, this set is
redundant. Removed the const, the LiveTrackerProps field, the prop plumbing in
LiveTracker + AnalyticsView, and its two tests. hasMajorConditionIssue stays
(used by the survey-flag count).
Typecheck + lint clean; live-tracker tests (64) + full suite (500) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
24 Charlesworth Street (deal 507693469922) has a major condition issue recorded
(major_condition_issue_description = "Damp in bathroom") but didn't appear in the
live tracker's condition-issue surfaces. Two causes, both now keyed on the issue
itself via a shared hasMajorConditionIssue(deal) predicate:
- majorConditionDeals filtered on dealstage === "3061261536" — a HubSpot stage
that was retired, so the set was always empty. (This set is also not yet
rendered in any card — noted for follow-up.)
- The rendered "Damp, Mould & Other Condition Issues → Flagged at Survey" count
(computeDampMouldRisk.surveyFlagDeals) keyed on major_condition_issue_evidence_s3_url,
which is null for this deal (its photos are in the raw HubSpot field). This is
the count the property was actually missing from.
There is no yes/no column; the description is the "Yes" signal (only filled when
an issue exists). Removed the now-dead MAJOR_CONDITION_STAGE_ID. TDD:
red→green→refactor; 66 live-tracker tests, full suite (502) + typecheck + lint green.
Also gitignore .sandcastle/ (untracked agent worktree scratch).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Static Stitch export previously only available locally; referenced by the
Ara Projects issues (#405-#429). README explains which parts are spec vs
placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sandcastle agent worktree (.sandcastle/worktrees/claude-assessment-model-p2)
— a full stale duplicate of src/ plus its own package-lock.json — had been
committed, inflating the branch diff by ~750k lines. Untrack it (local copy
kept) and gitignore .sandcastle/ so it can't recur.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scenario-metrics route test now imports a module chain
(route → overlay.ts) that carries `import "server-only"`, which Vite can't
resolve to a Node entry. Alias it to an empty stub so the guard stays in app
code while Node-env tests load. Full suite: 600 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CONTEXT.md: add the Scenario overlay glossary term under Reporting.
- ADR-0015: the Scenario overlay is one read model behind both metrics endpoints.
- ADR-0016: the SAP↔EPC band threshold ladder has a single owner (@/lib/epc).
- ADR-0017: Likely downgrade is band-movement only; the SAP-05 signal is retired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Money formatters: model.ts owns the single formatMoneyCompact + moneyFull;
components/primitives re-exports them (moneyCompact = formatMoneyCompact) and
the PDF's local money() is gone.
- ReportView: viewState.ts is the single definition; ScenarioCombobox re-exports
it instead of re-declaring.
- EPC_BANDS: the reporting API routes import the canonical @/lib/epc/bands
rather than the scenarios-model copy.
- compare/page uses countBelowBand(toBandCounts(...), "C") instead of a
hardcoded ["D","E","F","G"] reduce.
Typecheck + 95 reporting/epc tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Likely downgrade had two live definitions over near-disjoint populations: the
confidence strip used the legacy SAP-05 point comparison (legacy homes only),
while the data-quality page and drill-down used band movement (which yields 0
for legacy homes, whose lodged and effective band are the same column). So the
two surfaces could disagree for the same portfolio.
CONTEXT.md already calls the point-level signal "retired". This makes band
movement the single definition (lodged band vs effective band; real
certificates only):
- New shared SQL fragments likelyDowngradeSql / likelyUpgradeSql in
epcSources.ts — the SQL twin of model.classifyBandMovement.
- server.getLikelyDowngrades now counts band movement (was SAP-05, legacy-only),
so the confidence strip agrees with the data-quality page.
- getDataQualityMetrics and the drill-down route use the shared fragments.
Legacy portfolios now report likely-downgrades via band movement (0 where lodged
== effective), consistent everywhere. Typecheck + 86 reporting tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The block turning a ScenarioOverlay + current-stock totals into the ledger view
(carbonSaved, billSavings, cost-per-SAP, cost-per-carbon) was hand-written three
times — ReportingClientArea, the PDF page, and (partially) the Compare table —
where the ratios could silently disagree.
- model.ts gains deriveLedgerView() plus the atomic helpers amountSaved,
capitalOutlay, costPerSapPoint, costPerCarbonSaved. LedgerView moves here too
(re-exported from InvestmentLedger for existing imports).
- ReportingClientArea and pdf/page now call deriveLedgerView; their overlay
fetches are typed as ScenarioOverlay (candidate 3 wiring).
- Compare reuses the atomic helpers, keeping its null-for-"—" rendering.
- model.test.ts covers the derivation and its divide-by-zero guards.
Typecheck + 38 model tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "After scenario" aggregate — the back half of Current → After — was
written twice: scenario/[scenarioId]/metrics (488 lines) and
scenario/default/metrics (316), differing only in plan scope. They drifted
independently (band thresholds, compliance-window predicate, ledger wiring all
duplicated) and sat outside the reporting folder.
Candidate 1+3+4 from the architecture review:
- New @/lib/reporting/overlay.ts owns all overlay SQL + shaping behind one
getScenarioOverlay(portfolioId, scope, filters, tags). The default
(recommended-plans) view is the scenario view with an inert filter set and no
EPC target, so it flows through the same code. Joins are now conditional on
what the active filters read, so an unfiltered overlay never joins the
lodged-certificate / legacy tables — strictly leaner than before. Dropped the
plans-only avg_*/total_* columns that were computed but never read.
- Both metrics routes are now thin adapters: parse+validate the request, call
the read model, return it (or 404).
- ScenarioOverlay is a single typed contract in ./model (client-safe), replacing
the untyped JSON re-typed across consumers. Dropped dead ScenarioOverlayMetrics
and MetricKey.
- Reporting reads leave the Next.js route folder for @/lib/reporting, matching
the repo's src/lib/<domain>/server.ts convention: databaseFunctions.ts +
measuresQuery.ts → server.ts; types.ts → types.ts; reportingScenarios.ts →
moved with its test. actions.ts stays as the "use server" seam.
- Deleted the dead reporting/utils.ts.
Behaviour-preserving (getLikelyDowngrades kept on the SAP-05 path here; retired
in the next commit). Typecheck + 89 reporting/epc tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 92/81/69/55/39/21 band floors were defined in five places across TS and
SQL — @/app/utils.sapToEpc, a hand-copied sapToEpcLetter in actions, the
EPC_TO_SAP_MIN/MAX ranges, and the BAND_BUCKETS_SQL/EPC_MIN_SAP in both
scenario-metrics routes. "What SAP score is band C" now lives once in
@/lib/epc/thresholds (BAND_MIN_SAP), with a SQL twin in thresholdsSql.
- lib/epc/thresholds.ts: BAND_MIN_SAP, sapToBand, EPC_TO_SAP_MIN/MAX (derived)
- lib/epc/thresholdsSql.ts: sapBandBucketsSql() built from the same ladder
- @/app/utils.sapToEpc + @/app/utils/epc now delegate to the canonical ladder
- deleted the hand-copied sapToEpcLetter in actions/recommendations.ts
- both metrics routes use sapBandBucketsSql / BAND_MIN_SAP
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The left column (EPC distribution + About box) and the right column
(Investment ledger) ended at slightly different heights. Make the two
report columns equal height (items-stretch) and let each column's bottom
box grow to fill — the About/Recommended brief and the ledger card both
flex-1 — so their bottom edges always align regardless of content length.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-PR structural changes to the Projects module schema (migration
regenerated; not yet applied):
- Delete the contractor table. Contractors are organisations:
project_workstream_contractor.contractor_id -> organisation_id
(uuid -> organisation).
- Rename project.client_id -> organisation_id (uuid -> organisation).
- uploaded_files: drop project_workstream_id and
project_workstream_contractor_id; keep
project_workstream_evidence_requirement_id; add nullable work_order_id
(evidence is per work order, not per project workstream).
- Rewire relations accordingly and regenerate migration 0273.
- Reconcile schema.md with all of the above.
Note: deleting contractor drops its phone_number / email_address, which
have no equivalent on organisation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>