From e58a90a17b081a4bcc7443a27c9c260aeb6755b8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 15:45:43 +0000 Subject: [PATCH 01/37] docs: ModellingRun + Run filter language and ADR-0008 (filters to distributor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grilling-session outcomes for the bulk-trigger-modelling feature: the ModellingRun/Run-filter glossary entries and the decision record — insert-only run rows, filters (not ids) to the distributor lambda, backend-owned filter resolution with a shared-code-path count endpoint, run_id correlation into task records for derived in-flight state, warn-don't-block concurrency. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 8 ++ ...8-modelling-runs-filters-to-distributor.md | 87 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/adr/0008-modelling-runs-filters-to-distributor.md diff --git a/CONTEXT.md b/CONTEXT.md index 46f0f7a9..9d60223d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -104,6 +104,14 @@ _Avoid_: invasive (unused here), wet trades (a narrower, overlapping set: plaste A Scenario constraint that takes a measure off the table for the optimiser. The *only* measure-constraint concept on a Scenario — there is no stored inclusion list; "only Solar PV + ASHP" is expressed by excluding everything else. An empty exclusion set is valid and means every measure is available. _Avoid_: inclusions, allowed measures (as a stored concept — UI may present "allowed/excluded" toggles, but what's captured is the exclusion set) +**Modelling run**: +One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. An insert-only record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them. Execution progress is a separate concern (the pipeline's task records own it); a Modelling run is the request and its provenance, not the work. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read. +_Avoid_: job, batch, trigger request, modelling task (the pipeline's execution records) + +**Run filter**: +The property-selecting constraints of a Modelling run: postcode, property type, and built form (each multi-select; tags later). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all. Resolution follows the Landlord-override rule: an override fact beats an EPC-derived value; the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend). +_Avoid_: property query, segment, search (the postcode-search journey is unrelated) + ## Lifecycle A **BulkUpload** moves through these statuses: diff --git a/docs/adr/0008-modelling-runs-filters-to-distributor.md b/docs/adr/0008-modelling-runs-filters-to-distributor.md new file mode 100644 index 00000000..dd95ee69 --- /dev/null +++ b/docs/adr/0008-modelling-runs-filters-to-distributor.md @@ -0,0 +1,87 @@ +# 8. Modelling runs pass filters to the distributor; the backend owns resolution + +Date: 2026-07-06 + +## Status + +Accepted + +Numbering note: 0004–0006 are referenced by CONTEXT.md but were lost to the old +`docs/adr/**` gitignore rule; 0008 avoids all collisions (0003 = app-authored +scenarios, 0007 = postcode search). + +## Context + +Users can now add properties (postcode search, bulk upload) and author +Scenarios (ADR-0003) before any modelling exists. The missing step is +triggering modelling for *chosen* properties: many Scenarios × a subset of the +portfolio, potentially 50,000 properties. The entry point on the Model side is +a **distributor** lambda — it fans work out to modelling workers, it does not +model synchronously. ADR-0003 recorded a known follow-up: the in-flight +"modelling running" state is not representable by derivation and needs an +explicit marker when trigger wiring lands. This is that wiring. + +## Decision + +- **A `ModellingRun` is recorded, insert-only, by Next.js at trigger time**: + portfolio, the selected scenario ids, the Run filters, the matched-property + count shown at preview, who triggered it, when. It is the request and its + provenance — Plans become traceable to the selection that produced them. + It carries no status column (mirrors ADR-0003's derived-state rule). +- **The distributor receives `{run_id, portfolio_id, scenario_ids, filters}`** + — filters, never property-id lists. The payload stays tiny at any portfolio + size; the run row is the durable copy of the same config. +- **The modelling backend is the single authority for resolving filters to + properties.** Resolution precedence: landlord-override fact → EPC-derived + value (new-approach EPC graph, lodged over predicted, or legacy property-row + columns) → **Unknown**. The app never re-implements this rule. +- **Preview counts come from a backend dry-run/count endpoint** taking the + same filters through the same resolution code path as the real run — the + preview is a promise, not an estimate. The response includes per-scenario + already-modelled counts so the UI can flag re-modelling ("120 of 214 will + be re-modelled"). Plan arithmetic is exact: one plan per + (scenario, property) per run. +- **Filter options are resolution-free in the app**: the postcode multi-select + is one `SELECT DISTINCT postcode` per portfolio; type/built-form options are + the static canonical enums plus Unknown. Filters are bounded (cap the + postcode multi-select) as a UX guard; "all" is an absent filter, never an + enumeration. +- **Run status is derived by correlation, not stored**: the distributor stamps + `run_id` on the task records the pipeline already writes + (`tasks`/`sub_tasks`, service `plan_engine`). Scenario badges gain a third + derived state — "Modelling in progress" — and a per-portfolio run history + lists each run's filters, counts, initiator and derived status. No live + progress bars in v1. +- **Concurrent runs warn, never block**: triggering a scenario with a run in + flight shows who started it and when, but proceeds. Plans append and + latest-per-property wins on read, so overlap is safe; a hard lock could leak + on a dead run. + +## Alternatives considered + +- **Pass resolved property ids to the distributor**: makes preview-vs-run + drift impossible by construction, but ~50k ids is a payload the async entry + point can't take, and it moves the resolution rule into the app — the + backend already owns it for modelling itself. +- **App-side count queries mirroring the resolution rule**: no backend + dependency for the preview, but the overrides-else-EPC rule would live in + two codebases and the shown count could disagree with what runs. +- **Status columns on the run row (two-writer, as bulk uploads)**: simple + reads, but a second source of truth that can silently never arrive; task + correlation reuses records the pipeline writes anyway. +- **Blocking concurrent runs**: prevents accidental double-spend but a stalled + pipeline would lock the scenario until someone clears task rows by hand. + +## Consequences + +- Two backend asks must land with the distributor: accept + persist `run_id` + into task records, and expose the dry-run/count endpoint sharing the + resolution code path. +- The preview's already-modelled callout and matched counts are only as fresh + as trigger time; a property added between preview and trigger is picked up + by the run (filters re-resolve) — the run row records the previewed count so + discrepancies are visible after the fact. +- Reporting/scenario UIs gain a third derived scenario state; anything + assuming "has a run row ⇒ has plans" is wrong (a dead run may produce none). +- The tagging system (future) extends Run filters without touching the + contract shape: tags become one more optional filter key. From adcc9b8b8315e79ba2c89d1ac78459c6a921b379 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 16:11:08 +0000 Subject: [PATCH 02/37] docs(adr): record run-completion email notification as a known follow-up The dispatch confirmation sets a time expectation only; the email promise stays out of the UI until a completion notification exists (run_id -> task correlation provides the signal to build it on). Co-Authored-By: Claude Fable 5 --- docs/adr/0008-modelling-runs-filters-to-distributor.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/adr/0008-modelling-runs-filters-to-distributor.md b/docs/adr/0008-modelling-runs-filters-to-distributor.md index dd95ee69..9d72e06f 100644 --- a/docs/adr/0008-modelling-runs-filters-to-distributor.md +++ b/docs/adr/0008-modelling-runs-filters-to-distributor.md @@ -85,3 +85,8 @@ explicit marker when trigger wiring lands. This is that wiring. assuming "has a run row ⇒ has plans" is wrong (a dead run may produce none). - The tagging system (future) extends Run filters without touching the contract shape: tags become one more optional filter key. +- Known follow-up: **email notification on run completion**. The dispatch + confirmation sets a time expectation only; "we'll email you when results + are ready" was deliberately kept out of the UI copy until a completion + notification exists. The run_id → task correlation gives the completion + signal to hang it on. From c7dfe95e15ba28f68c28e2ad7e2a31b136d183aa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 16:27:21 +0000 Subject: [PATCH 03/37] docs(adr): run record is an app-created task, not a new table Amends ADR-0008 after challenge: the Modelling run reuses the BulkUpload trigger convention (app inserts the tasks row + config subtask with inputs JSON, passes task_id to the distributor) instead of a dedicated modelling_run table. No migration; status and progress come from the task system by construction; dispatch failure marks the task failed. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 2 +- ...8-modelling-runs-filters-to-distributor.md | 48 ++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 9d60223d..33bf900a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -105,7 +105,7 @@ A Scenario constraint that takes a measure off the table for the optimiser. The _Avoid_: inclusions, allowed measures (as a stored concept — UI may present "allowed/excluded" toggles, but what's captured is the exclusion set) **Modelling run**: -One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. An insert-only record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them. Execution progress is a separate concern (the pipeline's task records own it); a Modelling run is the request and its provenance, not the work. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read. +One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. A durable record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them, alongside how the work is going. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read. _Avoid_: job, batch, trigger request, modelling task (the pipeline's execution records) **Run filter**: diff --git a/docs/adr/0008-modelling-runs-filters-to-distributor.md b/docs/adr/0008-modelling-runs-filters-to-distributor.md index 9d72e06f..dab4e11e 100644 --- a/docs/adr/0008-modelling-runs-filters-to-distributor.md +++ b/docs/adr/0008-modelling-runs-filters-to-distributor.md @@ -23,14 +23,23 @@ explicit marker when trigger wiring lands. This is that wiring. ## Decision -- **A `ModellingRun` is recorded, insert-only, by Next.js at trigger time**: - portfolio, the selected scenario ids, the Run filters, the matched-property - count shown at preview, who triggered it, when. It is the request and its - provenance — Plans become traceable to the selection that produced them. - It carries no status column (mirrors ADR-0003's derived-state rule). -- **The distributor receives `{run_id, portfolio_id, scenario_ids, filters}`** +- **A Modelling run is recorded as an app-created task** — the BulkUpload + trigger convention (`triggerAddressMatching`): Next.js inserts the `tasks` + row (service `modelling_run`, `source_id` = portfolio, status `waiting`) + plus one config `sub_task` whose `inputs` JSON carries + `{scenario_ids, filters, previewed_property_count, triggered_by}`, then + passes `task_id` to the distributor, which attaches its execution subtasks + to the same task. **No new table, no migration.** Run history and the + per-scenario in-flight badge read the portfolio's own `modelling_run` tasks + (portfolio-scoped, a handful of rows) and parse the app-authored inputs + JSON; execution progress ("61 of 104 ready") falls out of the existing + subtask-count summary pattern. +- **The distributor receives `{task_id, portfolio_id, scenario_ids, filters}`** — filters, never property-id lists. The payload stays tiny at any portfolio - size; the run row is the durable copy of the same config. + size; the config subtask is the durable copy of the same request. +- **Dispatch failure marks the task `failed`** (an improvement on the + bulk-upload flow, which leaves failed triggers at `waiting`), so history + never shows a ghost run as pending. - **The modelling backend is the single authority for resolving filters to properties.** Resolution precedence: landlord-override fact → EPC-derived value (new-approach EPC graph, lodged over predicted, or legacy property-row @@ -46,12 +55,11 @@ explicit marker when trigger wiring lands. This is that wiring. the static canonical enums plus Unknown. Filters are bounded (cap the postcode multi-select) as a UX guard; "all" is an absent filter, never an enumeration. -- **Run status is derived by correlation, not stored**: the distributor stamps - `run_id` on the task records the pipeline already writes - (`tasks`/`sub_tasks`, service `plan_engine`). Scenario badges gain a third - derived state — "Modelling in progress" — and a per-portfolio run history - lists each run's filters, counts, initiator and derived status. No live - progress bars in v1. +- **Run status is the task's status** — no correlation machinery needed: the + run and the execution share one record by construction. Scenario badges + gain a third derived state — "Modelling in progress" — and a per-portfolio + run history lists each run's filters, counts, initiator and derived status + with subtask-count progress. - **Concurrent runs warn, never block**: triggering a scenario with a run in flight shows who started it and when, but proceeds. Plans append and latest-per-property wins on read, so overlap is safe; a hard lock could leak @@ -59,6 +67,13 @@ explicit marker when trigger wiring lands. This is that wiring. ## Alternatives considered +- **A dedicated `modelling_run` table (+ scenarios join table)**: relational + scenario linkage and typed columns for who/filters/counts, but a migration, + a second record system beside tasks, and correlation machinery (either the + backend stamping run ids into tasks, or the app storing returned task ids). + The task system already records app-initiated pipeline work with inputs, + progress and status — and the badge/history queries are portfolio-scoped + over a handful of rows, so the JSON parse costs nothing that matters. - **Pass resolved property ids to the distributor**: makes preview-vs-run drift impossible by construction, but ~50k ids is a payload the async entry point can't take, and it moves the resolution rule into the app — the @@ -74,9 +89,10 @@ explicit marker when trigger wiring lands. This is that wiring. ## Consequences -- Two backend asks must land with the distributor: accept + persist `run_id` - into task records, and expose the dry-run/count endpoint sharing the - resolution code path. +- Two backend asks must land with the distributor: accept an app-created + `task_id` and attach execution subtasks to it (rather than creating its own + task, as the current `plan_engine` entrypoint does), and expose the + dry-run/count endpoint sharing the resolution code path. - The preview's already-modelled callout and matched counts are only as fresh as trigger time; a property added between preview and trigger is picked up by the run (filters re-resolve) — the run row records the previewed count so From 35eb681f8fe1b84a56229dc4e29ed848ef490736 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 16:51:55 +0000 Subject: [PATCH 04/37] =?UTF-8?q?feat(modelling-runs):=20pure=20domain=20c?= =?UTF-8?q?ore=20=E2=80=94=20filters,=20run=20record,=20status,=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD'd per ADR-0008: normaliseRunFilters (canonical postcodes, enum vocabularies + Unknown, 40-postcode cap, absence = all), buildRunRecord / parseRunConfig (app-authored task + config-subtask inputs, null-safe round-trip), selectionSummary, deriveRunStatus (dispatched / in-progress with subtask progress / complete / failed), isLargeRun (10k gate). Co-Authored-By: Claude Fable 5 --- src/lib/modellingRuns/model.test.ts | 134 +++++++++++++++++++ src/lib/modellingRuns/model.ts | 198 ++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 src/lib/modellingRuns/model.test.ts create mode 100644 src/lib/modellingRuns/model.ts diff --git a/src/lib/modellingRuns/model.test.ts b/src/lib/modellingRuns/model.test.ts new file mode 100644 index 00000000..656a72fd --- /dev/null +++ b/src/lib/modellingRuns/model.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + buildRunRecord, + deriveRunStatus, + isLargeRun, + normaliseRunFilters, + parseRunConfig, + selectionSummary, +} from "./model"; + +describe("normaliseRunFilters", () => { + it("canonicalises postcodes, dedupes, and drops empty selections (absent = all)", () => { + const out = normaliseRunFilters({ + postcodes: ["m20 4tf", "M20 4TF", "b93 8su"], + propertyTypes: [], + builtForms: undefined, + }); + expect(out).toEqual({ + ok: true, + filters: { postcodes: ["B93 8SU", "M20 4TF"] }, + }); + }); + + it("accepts canonical types/built forms plus Unknown, rejects anything else", () => { + const ok = normaliseRunFilters({ + propertyTypes: ["House", "Unknown"], + builtForms: ["Semi-Detached"], + }); + expect(ok).toEqual({ + ok: true, + filters: { propertyTypes: ["House", "Unknown"], builtForms: ["Semi-Detached"] }, + }); + expect(normaliseRunFilters({ propertyTypes: ["Castle"] }).ok).toBe(false); + expect(normaliseRunFilters({ builtForms: ["Terraced"] }).ok).toBe(false); + }); + + it("caps the postcode selection at 40 — 'all postcodes' is absence, not enumeration", () => { + const many = Array.from({ length: 41 }, (_, i) => `M${Math.floor(i / 10)}${i % 10} 4TF`); + const out = normaliseRunFilters({ postcodes: many }); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.error).toMatch(/40/); + expect(normaliseRunFilters({ postcodes: many.slice(0, 40) }).ok).toBe(true); + }); +}); + +describe("buildRunRecord", () => { + it("produces the app-authored task row and config-subtask inputs (ADR-0008)", () => { + const record = buildRunRecord({ + portfolioId: "814", + scenarioIds: ["12", "34"], + filters: { postcodes: ["B93 8SU"], propertyTypes: ["House"] }, + previewedPropertyCount: 214, + triggeredBy: "khalim@domna.homes", + }); + expect(record.task).toEqual({ + taskSource: "Modelling run – 2 scenarios", + service: "modelling_run", + source: "portfolio_id", + sourceId: "814", + status: "waiting", + }); + expect(record.configInputs).toEqual({ + portfolio_id: 814, + scenario_ids: [12, 34], + filters: { postcodes: ["B93 8SU"], property_types: ["House"] }, + previewed_property_count: 214, + triggered_by: "khalim@domna.homes", + }); + }); +}); + +describe("parseRunConfig", () => { + it("round-trips a stored run record back to the app's shape", () => { + const { configInputs } = buildRunRecord({ + portfolioId: "814", + scenarioIds: ["12"], + filters: { builtForms: ["Detached", "Unknown"] }, + previewedPropertyCount: 57, + triggeredBy: "priya@domna.homes", + }); + expect(parseRunConfig(JSON.stringify(configInputs))).toEqual({ + scenarioIds: ["12"], + filters: { builtForms: ["Detached", "Unknown"] }, + previewedPropertyCount: 57, + triggeredBy: "priya@domna.homes", + }); + }); + + it("returns null for missing or malformed inputs — never throws on old rows", () => { + expect(parseRunConfig(null)).toBeNull(); + expect(parseRunConfig("not json")).toBeNull(); + expect(parseRunConfig('{"unrelated":true}')).toBeNull(); + }); +}); + +describe("selectionSummary", () => { + it("describes the filters in the run-history format; no filters = All properties", () => { + expect(selectionSummary({})).toBe("All properties"); + expect( + selectionSummary({ + postcodes: ["B93 8SU", "M20 4TF"], + propertyTypes: ["House"], + builtForms: ["Detached", "Unknown"], + }), + ).toBe("Postcodes: B93 8SU, M20 4TF · Type: House · Built form: Detached, Unknown"); + }); +}); + +describe("deriveRunStatus", () => { + const base = { taskStatus: "waiting", jobCompleted: null, total: 0, completed: 0, failed: 0 }; + it("maps the task lifecycle to the four display states", () => { + // dispatched: accepted, no execution subtasks yet + expect(deriveRunStatus(base)).toEqual({ state: "dispatched" }); + // in progress once execution subtasks exist, with progress + expect( + deriveRunStatus({ ...base, taskStatus: "in progress", total: 104, completed: 61 }), + ).toEqual({ state: "in_progress", ready: 61, total: 104 }); + // complete when the task says so + expect( + deriveRunStatus({ ...base, taskStatus: "completed", jobCompleted: new Date(), total: 104, completed: 104 }), + ).toEqual({ state: "complete" }); + // failed statuses win regardless of counts + expect( + deriveRunStatus({ ...base, taskStatus: "Failed", total: 104, completed: 40, failed: 3 }), + ).toEqual({ state: "failed" }); + }); +}); + +describe("isLargeRun", () => { + it("gates runs of 10,000+ matched properties behind an extra confirm", () => { + expect(isLargeRun(9999)).toBe(false); + expect(isLargeRun(10000)).toBe(true); + }); +}); diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts new file mode 100644 index 00000000..02cd9e41 --- /dev/null +++ b/src/lib/modellingRuns/model.ts @@ -0,0 +1,198 @@ +/** + * Modelling-run domain model — pure logic for the bulk-trigger-modelling + * journey. See CONTEXT.md (Modelling run, Run filter) and ADR-0008. + */ + +import { normalisePostcode } from "@/lib/postcodeSearch/model"; +import { + BuiltFormTypeValues, + PropertyTypeValues, +} from "@/app/db/schema/landlord_overrides"; + +/** An absent key means unconstrained — "all" is never an enumeration. */ +export interface RunFilters { + postcodes?: string[]; + propertyTypes?: string[]; + builtForms?: string[]; +} + +export type NormaliseFiltersResult = + | { ok: true; filters: RunFilters } + | { ok: false; error: string }; + +/** UX guard, not a transport limit — the config is stored, not carried. */ +export const MAX_FILTER_POSTCODES = 40; + +export function normaliseRunFilters(input: { + postcodes?: string[]; + propertyTypes?: string[]; + builtForms?: string[]; +}): NormaliseFiltersResult { + const filters: RunFilters = {}; + if (input.postcodes?.length) { + const seen = new Set(); + for (const raw of input.postcodes) { + const pc = normalisePostcode(raw); + if (!pc) return { ok: false, error: `Invalid postcode '${raw}'` }; + seen.add(pc); + } + filters.postcodes = [...seen].sort(); + if (filters.postcodes.length > MAX_FILTER_POSTCODES) { + return { + ok: false, + error: `Filters are limited to ${MAX_FILTER_POSTCODES} postcodes per run — leave the filter empty to include all postcodes`, + }; + } + } + // The filter vocabularies are the canonical enum lists (which already end + // in "Unknown" — the no-resolvable-value bucket, see CONTEXT.md Run filter). + for (const [key, allowed] of [ + ["propertyTypes", PropertyTypeValues], + ["builtForms", BuiltFormTypeValues], + ] as const) { + const values = input[key]; + if (!values?.length) continue; + const bad = values.find((v) => !allowed.includes(v)); + if (bad !== undefined) { + return { ok: false, error: `Invalid ${key === "propertyTypes" ? "property type" : "built form"} '${bad}'` }; + } + filters[key] = [...new Set(values)]; + } + return { ok: true, filters }; +} + +export interface RunConfigView { + scenarioIds: string[]; + filters: RunFilters; + previewedPropertyCount: number; + triggeredBy: string; +} + +/** + * Read a stored config-subtask `inputs` back into the app's shape. Null for + * anything that isn't a modelling-run config (malformed, legacy, absent) — + * history reads must never throw on old rows. + */ +export function parseRunConfig(inputs: string | null): RunConfigView | null { + if (!inputs) return null; + try { + const raw = JSON.parse(inputs) as Partial; + if (!Array.isArray(raw.scenario_ids) || typeof raw.triggered_by !== "string") { + return null; + } + const filters: RunFilters = {}; + if (raw.filters?.postcodes?.length) filters.postcodes = raw.filters.postcodes; + if (raw.filters?.property_types?.length) filters.propertyTypes = raw.filters.property_types; + if (raw.filters?.built_forms?.length) filters.builtForms = raw.filters.built_forms; + return { + scenarioIds: raw.scenario_ids.map(String), + filters, + previewedPropertyCount: raw.previewed_property_count ?? 0, + triggeredBy: raw.triggered_by, + }; + } catch { + return null; + } +} + +/** Above this, the UI asks for an explicit extra confirmation before running. */ +export const LARGE_RUN_THRESHOLD = 10_000; + +export function isLargeRun(matchedPropertyCount: number): boolean { + return matchedPropertyCount >= LARGE_RUN_THRESHOLD; +} + +export type RunStatus = + | { state: "dispatched" } + | { state: "in_progress"; ready: number; total: number } + | { state: "complete" } + | { state: "failed" }; + +/** + * Display state of a run from its task + execution-subtask counts (the config + * subtask is excluded by the caller). Status strings follow the task system's + * loose conventions (case-insensitive; completion also signalled by + * job_completed). + */ +export function deriveRunStatus(input: { + taskStatus: string; + jobCompleted: Date | null; + total: number; + completed: number; + failed: number; +}): RunStatus { + const s = input.taskStatus.toLowerCase(); + if (["failed", "failure", "error"].includes(s)) return { state: "failed" }; + if (input.jobCompleted || ["completed", "complete"].includes(s)) { + return { state: "complete" }; + } + if (input.total === 0) return { state: "dispatched" }; + return { state: "in_progress", ready: input.completed, total: input.total }; +} + +/** Human summary of a Run filter, as shown in run history ("All properties"). */ +export function selectionSummary(filters: RunFilters): string { + const bits: string[] = []; + if (filters.postcodes?.length) bits.push(`Postcodes: ${filters.postcodes.join(", ")}`); + if (filters.propertyTypes?.length) bits.push(`Type: ${filters.propertyTypes.join(", ")}`); + if (filters.builtForms?.length) bits.push(`Built form: ${filters.builtForms.join(", ")}`); + return bits.length ? bits.join(" · ") : "All properties"; +} + +export interface RunRequest { + portfolioId: string; + scenarioIds: string[]; + filters: RunFilters; + previewedPropertyCount: number; + triggeredBy: string; +} + +/** Backend-facing (snake_case) copy of a run's configuration. */ +export interface RunConfigInputs { + portfolio_id: number; + scenario_ids: number[]; + filters: { + postcodes?: string[]; + property_types?: string[]; + built_forms?: string[]; + }; + previewed_property_count: number; + triggered_by: string; +} + +/** + * The rows a Modelling run persists (ADR-0008): an app-authored task (the + * BulkUpload trigger convention) plus the config-subtask inputs that make the + * run's request durable and history-readable. + */ +export function buildRunRecord(req: RunRequest): { + task: { + taskSource: string; + service: "modelling_run"; + source: "portfolio_id"; + sourceId: string; + status: "waiting"; + }; + configInputs: RunConfigInputs; +} { + const filters: RunConfigInputs["filters"] = {}; + if (req.filters.postcodes) filters.postcodes = req.filters.postcodes; + if (req.filters.propertyTypes) filters.property_types = req.filters.propertyTypes; + if (req.filters.builtForms) filters.built_forms = req.filters.builtForms; + return { + task: { + taskSource: `Modelling run – ${req.scenarioIds.length} scenario${req.scenarioIds.length === 1 ? "" : "s"}`, + service: "modelling_run", + source: "portfolio_id", + sourceId: req.portfolioId, + status: "waiting", + }, + configInputs: { + portfolio_id: Number(req.portfolioId), + scenario_ids: req.scenarioIds.map(Number), + filters, + previewed_property_count: req.previewedPropertyCount, + triggered_by: req.triggeredBy, + }, + }; +} From 4fefec7ccad2c34d04fb46909675b81c78a9842d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 6 Jul 2026 16:54:47 +0000 Subject: [PATCH 05/37] feat(portfolio): separate properties without a UPRN into their own section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portfolio landing page listed every property in one table, mixing in the ones that never matched to an Ordnance Survey UPRN. Those unmatched properties need a different workflow (fix the address/postcode, then re-run the address→ UPRN search), so surface them separately above the main table. This change does the separation only: - getUnmatchedProperties(portfolioId): portfolio properties with uprn IS NULL (excluding soft-deleted), falling back to the user-inputted address/postcode when the canonical fields are empty. - UnmatchedProperties: an amber "needs attention" card listing address + postcode per row, with a placeholder "Search UPRN" action where the advanced ARA search will hang off. Renders nothing when every property is matched, so healthy portfolios stay uncluttered. - Portfolio page renders the section above the existing PropertyTable, which is left exactly as-is. Follow-up (the actual feature): make address/postcode editable per row and wire the "Search UPRN" button to the advanced ARA search. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/portfolio/[slug]/(portfolio)/page.tsx | 5 ++ .../[slug]/components/UnmatchedProperties.tsx | 86 +++++++++++++++++++ src/lib/properties/unmatched.ts | 47 ++++++++++ 3 files changed, 138 insertions(+) create mode 100644 src/app/portfolio/[slug]/components/UnmatchedProperties.tsx create mode 100644 src/lib/properties/unmatched.ts diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index 4f9b0cde..d4514980 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -1,4 +1,6 @@ import PropertyTable from "../components/PropertyTable"; +import UnmatchedProperties from "../components/UnmatchedProperties"; +import { getUnmatchedProperties } from "@/lib/properties/unmatched"; export const revalidate = 60; @@ -11,8 +13,11 @@ export default async function Page(props: { const params = await props.params; const portfolioId = params.slug; + const unmatched = await getUnmatchedProperties(portfolioId); + return ( <> + ); diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx new file mode 100644 index 00000000..9d6ef33e --- /dev/null +++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx @@ -0,0 +1,86 @@ +import { + ExclamationTriangleIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/24/outline"; +import type { UnmatchedProperty } from "@/lib/properties/unmatched"; + +// The "needs attention" workspace that separates properties with no UPRN from +// the matched portfolio table. Read-only for now; the per-row action is where +// the advanced ARA search (edit address/postcode → search UPRN) will hang off. +// Renders nothing when every property is matched, so a healthy portfolio stays +// uncluttered. +export default function UnmatchedProperties({ + properties, +}: { + properties: UnmatchedProperty[]; +}) { + if (properties.length === 0) return null; + + const count = properties.length; + + return ( +
+
+ +
+
+

+ Properties without a UPRN +

+ + {count} + +
+

+ {count === 1 ? "This property" : "These properties"} couldn't be + matched to an Ordnance Survey address automatically. Check the address + and postcode, then run an advanced search to find the right match. +

+
+
+ +
+
+
Address
+
Postcode
+
Action
+
+ +
    + {properties.map((p) => ( +
  • +

    + {p.address ?? ( + No address + )} +

    +

    + {p.postcode ?? } +

    +
    + +
    +
  • + ))} +
+
+
+ ); +} diff --git a/src/lib/properties/unmatched.ts b/src/lib/properties/unmatched.ts new file mode 100644 index 00000000..9f8e6334 --- /dev/null +++ b/src/lib/properties/unmatched.ts @@ -0,0 +1,47 @@ +import { db } from "@/app/db/db"; +import { property } from "@/app/db/schema/property"; +import { and, asc, eq, isNull } from "drizzle-orm"; + +export interface UnmatchedProperty { + id: string; + address: string | null; + postcode: string | null; +} + +// Properties in a portfolio that have no UPRN — i.e. were never matched to an +// Ordnance Survey address record. We surface these separately from the main +// property table so the customer can correct the address/postcode and re-run +// the address→UPRN search (the "advanced ARA search", wired up in a follow-up). +// `markedForDeletion` rows are excluded so soft-deleted properties don't show. +export async function getUnmatchedProperties( + portfolioId: string, +): Promise { + if (!/^\d+$/.test(portfolioId)) return []; + const pid = BigInt(portfolioId); + + const rows = await db + .select({ + id: property.id, + address: property.address, + postcode: property.postcode, + userInputtedAddress: property.userInputtedAddress, + userInputtedPostcode: property.userInputtedPostcode, + }) + .from(property) + .where( + and( + eq(property.portfolioId, pid), + isNull(property.uprn), + eq(property.markedForDeletion, false), + ), + ) + .orderBy(asc(property.address)); + + // Prefer the canonical address; fall back to whatever the user typed on + // import so the row is still identifiable while it awaits a match. + return rows.map((r) => ({ + id: String(r.id), + address: r.address ?? r.userInputtedAddress ?? null, + postcode: r.postcode ?? r.userInputtedPostcode ?? null, + })); +} From 6c479542a0730e60be354ec83ee0db627c7968a2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 17:08:57 +0000 Subject: [PATCH 06/37] =?UTF-8?q?feat(modelling-runs):=20trigger=20journey?= =?UTF-8?q?=20=E2=80=94=20server=20module,=20routes,=20run=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the bulk-trigger-modelling journey per ADR-0008 on the TDD'd core: - Server module: triggerModellingRun (app-authored task + config subtask, BulkUpload trigger convention; dispatch to /v1/plan/trigger-run; failed dispatch marks the task failed), previewModellingRun (dry-run count proxy to /v1/plan/preview-run), listModellingRuns (one grouped portfolio-scoped query; config parsed back, progress from execution-subtask counts), scenarioIdsWithActiveRuns (third badge state). - Routes: POST/GET /api/portfolio/[id]/modelling-runs, POST .../preview, GET /api/portfolio/[id]/postcodes (grouped distinct + counts). - Page /portfolio/[slug]/modelling/run to the approved mockup in the scenarios-tab design language: scenario picker with the three badges and in-flight warning, three filter columns (postcodes from DB, canonical enums + Unknown), engine-counted summary tray with re-model callouts, large-run confirm (10k gate), done state with ETA, Recent runs table with live progress. TanStack Query v4; no useEffect/useMemo. - "Run modelling" button beside Add properties in PropertyTable. Backend contract (Model team): the distributor at /v1/plan/trigger-run accepts {task_id, portfolio_id, scenario_ids, filters, ...} and attaches execution subtasks to the given task; /v1/plan/preview-run returns {matched_properties, per_scenario[]} through the same resolution code path. Verified live against portfolio 814: postcodes, validation errors, unknown scenarios, dispatch failure marks the task failed and history shows it; simulated backend execution subtasks drove status to {in_progress, ready 2, total 4} and the scenario badge to "Modelling in progress" in the rendered page; portfolio page shows the button. tsc, lint and all 365 vitest tests pass. Co-Authored-By: Claude Fable 5 --- .../modelling-runs/preview/route.ts | 62 ++ .../[portfolioId]/modelling-runs/route.ts | 103 ++++ .../[portfolioId]/postcodes/route.ts | 42 ++ .../[slug]/components/PropertyTable.tsx | 10 + .../modelling/run/RunModellingClient.tsx | 582 ++++++++++++++++++ .../portfolio/[slug]/modelling/run/page.tsx | 38 ++ src/lib/modellingRuns/model.ts | 7 + src/lib/modellingRuns/server.ts | 244 ++++++++ 8 files changed, 1088 insertions(+) create mode 100644 src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/postcodes/route.ts create mode 100644 src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx create mode 100644 src/app/portfolio/[slug]/modelling/run/page.tsx create mode 100644 src/lib/modellingRuns/server.ts diff --git a/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts b/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts new file mode 100644 index 00000000..ee8c768f --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { normaliseRunFilters } from "@/lib/modellingRuns/model"; +import { previewModellingRun } from "@/lib/modellingRuns/server"; + +const bodySchema = z.object({ + scenarioIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50), + filters: z + .object({ + postcodes: z.array(z.string()).optional(), + propertyTypes: z.array(z.string()).optional(), + builtForms: z.array(z.string()).optional(), + }) + .default({}), +}); + +// POST — dry-run counts from the backend (same resolution code path as the +// real run; the preview is a promise, not an estimate — ADR-0008). +export async function POST( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + const normalised = normaliseRunFilters(body.filters); + if (!normalised.ok) { + return NextResponse.json({ error: normalised.error }, { status: 400 }); + } + + try { + const result = await previewModellingRun({ + portfolioId, + scenarioIds: [...new Set(body.scenarioIds)], + filters: normalised.filters, + sessionToken: + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value, + }); + if (!result.ok) { + return NextResponse.json({ error: result.message }, { status: result.status }); + } + return NextResponse.json(result.data); + } catch (err) { + console.error("POST /modelling-runs/preview error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts b/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts new file mode 100644 index 00000000..b62f8078 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { normaliseRunFilters } from "@/lib/modellingRuns/model"; +import { + listModellingRuns, + triggerModellingRun, +} from "@/lib/modellingRuns/server"; + +const filtersSchema = z.object({ + postcodes: z.array(z.string()).optional(), + propertyTypes: z.array(z.string()).optional(), + builtForms: z.array(z.string()).optional(), +}); + +const bodySchema = z.object({ + scenarioIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50), + filters: filtersSchema.default({}), + previewedPropertyCount: z.number().int().nonnegative(), +}); + +function sessionToken(request: NextRequest): string | undefined { + return ( + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value + ); +} + +// POST — record + dispatch a Modelling run (ADR-0008) +export async function POST( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + const normalised = normaliseRunFilters(body.filters); + if (!normalised.ok) { + return NextResponse.json({ error: normalised.error }, { status: 400 }); + } + + try { + const outcome = await triggerModellingRun({ + portfolioId, + scenarioIds: [...new Set(body.scenarioIds)], + filters: normalised.filters, + previewedPropertyCount: body.previewedPropertyCount, + triggeredBy: session.user.email, + sessionToken: sessionToken(request), + }); + switch (outcome.kind) { + case "ok": + return NextResponse.json({ taskId: outcome.taskId }, { status: 202 }); + case "unknown_scenarios": + return NextResponse.json( + { error: `Unknown scenarios for this portfolio: ${outcome.missing.join(", ")}` }, + { status: 400 }, + ); + case "dispatch_failed": + return NextResponse.json( + { error: "Couldn't start modelling — please try again" }, + { status: outcome.status }, + ); + } + } catch (err) { + console.error("POST /modelling-runs error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// GET — run history for the portfolio +export async function GET( + _request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + try { + return NextResponse.json({ runs: await listModellingRuns(portfolioId) }); + } catch (err) { + console.error("GET /modelling-runs error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/postcodes/route.ts b/src/app/api/portfolio/[portfolioId]/postcodes/route.ts new file mode 100644 index 00000000..0801470b --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/postcodes/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { property } from "@/app/db/schema/property"; +import { and, asc, count, eq, isNotNull } from "drizzle-orm"; + +// GET — distinct postcodes (with counts) for the Run-filter options. One +// grouped, portfolio-scoped query (ix_property_portfolio). +export async function GET( + _request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + try { + const rows = await db + .select({ postcode: property.postcode, n: count() }) + .from(property) + .where( + and( + eq(property.portfolioId, BigInt(portfolioId)), + eq(property.markedForDeletion, false), + isNotNull(property.postcode), + ), + ) + .groupBy(property.postcode) + .orderBy(asc(property.postcode)); + return NextResponse.json({ + postcodes: rows.map((r) => ({ postcode: r.postcode, count: r.n })), + }); + } catch (err) { + console.error("GET /postcodes error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 8fd7aed1..21f644b9 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -14,6 +14,7 @@ import { PlusIcon, MagnifyingGlassIcon, DocumentArrowUpIcon, + PlayIcon, } from "@heroicons/react/24/outline"; import { useRouter } from "next/navigation"; import { HomeIcon } from "@heroicons/react/24/outline"; @@ -672,6 +673,15 @@ export default function PropertyTable({ + + {/* Run modelling */} + diff --git a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx new file mode 100644 index 00000000..34202925 --- /dev/null +++ b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx @@ -0,0 +1,582 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { PlayIcon } from "@heroicons/react/24/solid"; +import { + RUN_FILTER_BUILT_FORMS, + RUN_FILTER_PROPERTY_TYPES, + RunFilters, + RunStatus, + isLargeRun, + MAX_FILTER_POSTCODES, +} from "@/lib/modellingRuns/model"; + +export interface ScenarioOption { + id: string; + name: string; + goal: string; + goalValue: string | null; + housingType: string; + budget: number | null; + status: "modelled" | "awaiting" | "running"; + modelledCount: number; +} + +interface PreviewResponse { + matchedProperties: number; + perScenario: { scenarioId: string; alreadyModelled: number }[]; +} + +interface RunHistoryRow { + taskId: string; + triggeredAt: string; + triggeredBy: string; + scenarios: { id: string; name: string | null }[]; + selection: string; + previewedPropertyCount: number; + planCount: number; + status: RunStatus; +} + +const STATUS_LABELS: Record = { + dispatched: "Dispatched", + in_progress: "In progress", + complete: "Complete", + failed: "Failed", +}; + +function RunBadge({ status }: { status: ScenarioOption["status"] }) { + if (status === "running") { + return ( + + + Modelling in progress + + ); + } + const modelled = status === "modelled"; + return ( + + + {modelled ? "Modelled" : "Awaiting modelling"} + + ); +} + +function FilterColumn({ + title, + options, + selected, + onToggle, +}: { + title: string; + options: { value: string; count?: number }[]; + selected: Set; + onToggle: (value: string) => void; +}) { + return ( +
+
+ + {title} + + + · {selected.size ? `${selected.size} selected` : "all"} + +
+
+ {options.map((o) => { + const on = selected.has(o.value); + return ( + + ); + })} +
+
+ ); +} + +export default function RunModellingClient({ + portfolioId, + portfolioName, + scenarios, +}: { + portfolioId: string; + portfolioName: string; + scenarios: ScenarioOption[]; +}) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [selScenarios, setSelScenarios] = useState>(new Set()); + const [selPcs, setSelPcs] = useState>(new Set()); + const [selTypes, setSelTypes] = useState>(new Set()); + const [selForms, setSelForms] = useState>(new Set()); + const [confirmOpen, setConfirmOpen] = useState(false); + const [done, setDone] = useState<{ properties: number; scenarios: number } | null>(null); + + const filters: RunFilters = { + ...(selPcs.size ? { postcodes: [...selPcs].sort() } : {}), + ...(selTypes.size ? { propertyTypes: [...selTypes] } : {}), + ...(selForms.size ? { builtForms: [...selForms] } : {}), + }; + const scenarioIds = [...selScenarios].sort(); + + const postcodesQuery = useQuery<{ postcode: string; count: number }[], Error>({ + queryKey: ["portfolio-postcodes", portfolioId], + staleTime: 5 * 60 * 1000, + queryFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/postcodes`); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't load postcodes"); + return body.postcodes; + }, + }); + + const preview = useQuery({ + queryKey: ["run-preview", portfolioId, scenarioIds, filters], + enabled: scenarioIds.length > 0 && !done, + keepPreviousData: true, + retry: false, + queryFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenarioIds, filters }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't count the selection"); + return body; + }, + }); + + const history = useQuery({ + queryKey: ["modelling-runs", portfolioId], + refetchInterval: 30_000, + queryFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't load run history"); + return body.runs; + }, + }); + + const run = useMutation({ + mutationFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scenarioIds, + filters, + previewedPropertyCount: preview.data?.matchedProperties ?? 0, + }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't start modelling — please try again"); + return body; + }, + onSuccess: () => { + setDone({ properties: preview.data?.matchedProperties ?? 0, scenarios: scenarioIds.length }); + setSelScenarios(new Set()); + queryClient.invalidateQueries(["modelling-runs", portfolioId]); + }, + }); + + const toggle = (set: Set, setter: (s: Set) => void, value: string) => { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + setter(next); + }; + + const matched = preview.data?.matchedProperties; + const planTotal = matched != null ? matched * scenarioIds.length : null; + const remodel = (preview.data?.perScenario ?? []).filter((p) => p.alreadyModelled > 0); + const scenarioById = new Map(scenarios.map((s) => [s.id, s])); + const startRun = () => { + if (matched != null && isLargeRun(matched)) setConfirmOpen(true); + else run.mutate(); + }; + const eta = planTotal ? Math.max(5, Math.round(planTotal / 300) * 5) : 5; + + return ( +
+
+ + {portfolioName} / Portfolio + {" "} + / Run modelling +
+

+ Run your scenarios. +

+

+ Choose the scenarios you want answered, then the homes to include. The counts you see are + checked against your portfolio as you go — what you see is exactly what gets modelled. +

+ + {done ? ( +
+
+ +
+

Modelling started

+

+ We're modelling {done.properties.toLocaleString()}{" "} + {done.properties === 1 ? "property" : "properties"} against {done.scenarios}{" "} + scenario{done.scenarios > 1 ? "s" : ""} —{" "} + {(done.properties * done.scenarios).toLocaleString()} plan + {done.properties * done.scenarios === 1 ? "" : "s"} in total. Results for a run this + size are typically ready within ~{eta} minutes. You can check on this run any + time under Recent runs. +

+
+ + +
+
+ ) : ( + <> + {/* Step 1 — scenarios */} +
+ + Step 1 · Scenarios + +

+ Which questions are we asking? +

+

+ Every selected scenario runs against the same property selection. +

+ {scenarios.length === 0 ? ( +
+ No scenarios yet —{" "} + + create your first scenario + {" "} + and come back to run it. +
+ ) : ( + scenarios.map((s) => { + const on = selScenarios.has(s.id); + return ( +
+ + {on && s.status === "running" && ( +
+ This scenario is already being modelled. You can still include it — the + newest results are the ones you'll see. +
+ )} +
+ ); + }) + )} +
+ + {/* Step 2 — properties */} +
+ + Step 2 · Properties + +

+ Which homes? +

+

+ Leave a filter untouched to include everything. What you've told us about a home + takes priority over its EPC; “Unknown” picks out homes where we have + neither. +

+
+ ({ + value: p.postcode, + count: p.count, + }))} + selected={selPcs} + onToggle={(v) => toggle(selPcs, setSelPcs, v)} + /> + ({ value }))} + selected={selTypes} + onToggle={(v) => toggle(selTypes, setSelTypes, v)} + /> + ({ value }))} + selected={selForms} + onToggle={(v) => toggle(selForms, setSelForms, v)} + /> +
+

+ Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run. Tags are coming — + you'll be able to save a selection and reuse it. +

+
+ + {/* Recent runs */} +
+
+

Recent runs

+ + what was asked, by whom, and how it went + +
+ {history.isLoading ? ( +

Loading…

+ ) : !history.data?.length ? ( +

+ No runs yet — your first appears here the moment you trigger it. +

+ ) : ( +
+ + + + {["When", "Scenarios", "Selection", "Properties", "Plans", "Status"].map( + (h) => ( + + ), + )} + + + + {history.data.map((r) => ( + + + + + + + + + ))} + +
+ {h} +
+ {new Date(r.triggeredAt).toLocaleString("en-GB", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + })} + {r.triggeredBy} + + {r.scenarios.map((s) => s.name ?? s.id).join(" · ") || "—"} + + {r.selection} + + {r.previewedPropertyCount.toLocaleString()} + + {r.status.state === "in_progress" ? ( + <> + + {r.status.ready.toLocaleString()} of {r.status.total.toLocaleString()} ready + + + + + + ) : ( + r.planCount.toLocaleString() + )} + + + {STATUS_LABELS[r.status.state]} + +
+
+ )} +
+ + )} + + {/* Summary tray */} + {!done && scenarioIds.length > 0 && ( +
+
+ + {matched != null ? matched.toLocaleString() : "—"} + properties + + × + + {scenarioIds.length} + + scenario{scenarioIds.length > 1 ? "s" : ""} + + + = + + {planTotal != null ? planTotal.toLocaleString() : "—"} + plans + + + + {preview.isFetching + ? "Counting…" + : preview.isError + ? preview.error.message + : "Checked against your portfolio just now"} + + +
+ {(remodel.length > 0 || run.isError) && ( +
+ {remodel.map((p) => ( + + {p.alreadyModelled.toLocaleString()} of{" "} + {matched?.toLocaleString() ?? "—"} have already been modelled in “ + {scenarioById.get(p.scenarioId)?.name ?? p.scenarioId}” — they'll be + modelled again and the newest results shown + + ))} + {run.isError && {run.error.message}} +
+ )} +
+ )} + + {/* Large-run confirm */} + {confirmOpen && ( +
+
+

+ This is a large run +

+

+ You're about to model {matched?.toLocaleString()} homes across{" "} + {scenarioIds.length} scenario{scenarioIds.length > 1 ? "s" : ""} —{" "} + {planTotal?.toLocaleString()} plans. A run this size can't be stopped + once it starts. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/modelling/run/page.tsx b/src/app/portfolio/[slug]/modelling/run/page.tsx new file mode 100644 index 00000000..9a686cb9 --- /dev/null +++ b/src/app/portfolio/[slug]/modelling/run/page.tsx @@ -0,0 +1,38 @@ +import { getPortfolio } from "../../utils"; +import { listScenariosWithStatus } from "@/lib/scenarios/queries"; +import { scenarioIdsWithActiveRuns } from "@/lib/modellingRuns/server"; +import RunModellingClient, { ScenarioOption } from "./RunModellingClient"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { params: Promise<{ slug: string }> }) { + const { slug } = await props.params; + const [{ name }, scenarios, activeIds] = await Promise.all([ + getPortfolio(slug), + listScenariosWithStatus(BigInt(slug)), + scenarioIdsWithActiveRuns(slug), + ]); + + const options: ScenarioOption[] = scenarios.map((s) => ({ + id: s.id.toString(), + name: s.name ?? `Scenario ${s.id}`, + goal: s.goal, + goalValue: s.goalValue, + housingType: s.housingType, + budget: s.budget, + status: activeIds.has(s.id.toString()) + ? "running" + : s.status === "modelled" + ? "modelled" + : "awaiting", + modelledCount: s.plansCount, + })); + + return ( + + ); +} diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts index 02cd9e41..c00fe73a 100644 --- a/src/lib/modellingRuns/model.ts +++ b/src/lib/modellingRuns/model.ts @@ -9,6 +9,13 @@ import { PropertyTypeValues, } from "@/app/db/schema/landlord_overrides"; +/** + * The Run-filter vocabularies: the canonical enum lists, which already end in + * "Unknown" — the no-resolvable-value bucket (CONTEXT.md, Run filter). + */ +export const RUN_FILTER_PROPERTY_TYPES: readonly string[] = PropertyTypeValues; +export const RUN_FILTER_BUILT_FORMS: readonly string[] = BuiltFormTypeValues; + /** An absent key means unconstrained — "all" is never an enumeration. */ export interface RunFilters { postcodes?: string[]; diff --git a/src/lib/modellingRuns/server.ts b/src/lib/modellingRuns/server.ts new file mode 100644 index 00000000..b20a1940 --- /dev/null +++ b/src/lib/modellingRuns/server.ts @@ -0,0 +1,244 @@ +import { db } from "@/app/db/db"; +import { tasks } from "@/app/db/schema/tasks/tasks"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; +import { scenario } from "@/app/db/schema/recommendations"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { + RunFilters, + RunStatus, + buildRunRecord, + deriveRunStatus, + parseRunConfig, + selectionSummary, +} from "./model"; + +// The config subtask that makes a run's request durable (ADR-0008); execution +// subtasks attached by the distributor carry other service values. +export const RUN_CONFIG_SERVICE = "modelling_run_config"; + +// Distributor entry point on the Model backend. It fans the run out to +// workers, attaching execution subtasks to the task_id we pass (ADR-0008). +const DISTRIBUTOR_ENDPOINT = "/v1/plan/trigger-run"; +// Dry-run sibling: same filter resolution, counts only. +const PREVIEW_ENDPOINT = "/v1/plan/preview-run"; + +type BackendResult = { ok: true; data: T } | { ok: false; status: number; message: string }; + +async function callBackend( + endpoint: string, + payload: Record, + sessionToken: string | undefined, +): Promise> { + const url = process.env.FASTAPI_API_URL; + const key = process.env.FASTAPI_API_KEY; + if (!url || !key) { + console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); + return { ok: false, status: 500, message: "Server misconfiguration" }; + } + try { + const res = await fetch(`${url}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": key, + Authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const errText = await res.text().catch(() => ""); + console.error(`FastAPI ${endpoint} failed:`, res.status, errText); + return { ok: false, status: 502, message: "Modelling backend request failed" }; + } + return { ok: true, data: (await res.json()) as T }; + } catch (err) { + console.error(`Failed to reach FastAPI ${endpoint}:`, err); + return { ok: false, status: 502, message: "Modelling backend request failed" }; + } +} + +export type TriggerRunOutcome = + | { kind: "ok"; taskId: string } + | { kind: "unknown_scenarios"; missing: string[] } + | { kind: "dispatch_failed"; status: number; message: string }; + +/** + * Record + dispatch a Modelling run: app-authored task and config subtask + * (BulkUpload trigger convention), then hand task_id to the distributor. A + * failed dispatch marks the task failed so history never shows a ghost run + * as pending (ADR-0008). + */ +export async function triggerModellingRun(args: { + portfolioId: string; + scenarioIds: string[]; + filters: RunFilters; + previewedPropertyCount: number; + triggeredBy: string; + sessionToken: string | undefined; +}): Promise { + const known = await db + .select({ id: scenario.id }) + .from(scenario) + .where( + and( + eq(scenario.portfolioId, BigInt(args.portfolioId)), + inArray(scenario.id, args.scenarioIds.map(BigInt)), + ), + ); + const knownIds = new Set(known.map((r) => r.id.toString())); + const missing = args.scenarioIds.filter((id) => !knownIds.has(id)); + if (missing.length > 0) return { kind: "unknown_scenarios", missing }; + + const record = buildRunRecord(args); + const now = new Date(); + const [task] = await db + .insert(tasks) + .values({ ...record.task, jobStarted: now }) + .returning(); + await db.insert(subTasks).values({ + taskId: task.id, + status: "waiting", + service: RUN_CONFIG_SERVICE, + inputs: JSON.stringify(record.configInputs), + }); + + const dispatch = await callBackend( + DISTRIBUTOR_ENDPOINT, + { task_id: task.id, ...record.configInputs }, + args.sessionToken, + ); + if (!dispatch.ok) { + await db.update(tasks).set({ status: "failed" }).where(eq(tasks.id, task.id)); + return { kind: "dispatch_failed", status: dispatch.status, message: dispatch.message }; + } + + await db.update(tasks).set({ status: "in progress" }).where(eq(tasks.id, task.id)); + return { kind: "ok", taskId: task.id }; +} + +export interface PreviewCounts { + matchedProperties: number; + perScenario: { scenarioId: string; alreadyModelled: number }[]; +} + +/** Proxy the backend dry-run: same resolution code path as the real run. */ +export async function previewModellingRun(args: { + portfolioId: string; + scenarioIds: string[]; + filters: RunFilters; + sessionToken: string | undefined; +}): Promise> { + const { configInputs } = buildRunRecord({ + ...args, + previewedPropertyCount: 0, + triggeredBy: "preview", + }); + const res = await callBackend<{ + matched_properties: number; + per_scenario?: { scenario_id: number; already_modelled: number }[]; + }>( + PREVIEW_ENDPOINT, + { + portfolio_id: configInputs.portfolio_id, + scenario_ids: configInputs.scenario_ids, + filters: configInputs.filters, + }, + args.sessionToken, + ); + if (!res.ok) return res; + return { + ok: true, + data: { + matchedProperties: res.data.matched_properties, + perScenario: (res.data.per_scenario ?? []).map((p) => ({ + scenarioId: String(p.scenario_id), + alreadyModelled: p.already_modelled, + })), + }, + }; +} + +export interface RunHistoryRow { + taskId: string; + triggeredAt: string; + triggeredBy: string; + scenarios: { id: string; name: string | null }[]; + selection: string; + previewedPropertyCount: number; + planCount: number; + status: RunStatus; +} + +/** + * Run history for a portfolio: its modelling_run tasks with app-authored + * config parsed back out and progress from execution-subtask counts. One + * grouped query — portfolio-scoped, never per-run probes. + */ +export async function listModellingRuns( + portfolioId: string, + limit = 20, +): Promise { + const rows = await db + .select({ + taskId: tasks.id, + jobStarted: tasks.jobStarted, + jobCompleted: tasks.jobCompleted, + status: tasks.status, + configInputs: sql`max(${subTasks.inputs}) filter (where ${subTasks.service} = ${RUN_CONFIG_SERVICE})`, + total: sql`count(${subTasks.id}) filter (where ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE})::int`, + completed: sql`count(case when lower(${subTasks.status}) in ('completed','complete') and ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE} then 1 end)::int`, + failed: sql`count(case when lower(${subTasks.status}) in ('failed','failure','error') and ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE} then 1 end)::int`, + }) + .from(tasks) + .leftJoin(subTasks, eq(subTasks.taskId, tasks.id)) + .where(and(eq(tasks.service, "modelling_run"), eq(tasks.sourceId, portfolioId))) + .groupBy(tasks.id) + .orderBy(desc(tasks.jobStarted)) + .limit(limit); + + const configs = rows.map((r) => parseRunConfig(r.configInputs)); + const scenarioIds = [...new Set(configs.flatMap((c) => c?.scenarioIds ?? []))]; + const names = scenarioIds.length + ? await db + .select({ id: scenario.id, name: scenario.name }) + .from(scenario) + .where(inArray(scenario.id, scenarioIds.map(BigInt))) + : []; + const nameById = new Map(names.map((n) => [n.id.toString(), n.name])); + + return rows.map((r, i) => { + const config = configs[i]; + const scenarios = (config?.scenarioIds ?? []).map((id) => ({ + id, + name: nameById.get(id) ?? null, + })); + return { + taskId: r.taskId, + triggeredAt: (r.jobStarted ?? new Date(0)).toISOString(), + triggeredBy: config?.triggeredBy ?? "unknown", + scenarios, + selection: config ? selectionSummary(config.filters) : "—", + previewedPropertyCount: config?.previewedPropertyCount ?? 0, + planCount: (config?.previewedPropertyCount ?? 0) * scenarios.length, + status: deriveRunStatus({ + taskStatus: r.status, + jobCompleted: r.jobCompleted, + total: r.total, + completed: r.completed, + failed: r.failed, + }), + }; + }); +} + +/** Scenario ids with a run currently in flight — drives the third badge state. */ +export async function scenarioIdsWithActiveRuns(portfolioId: string): Promise> { + const runs = await listModellingRuns(portfolioId, 50); + const active = new Set(); + for (const run of runs) { + if (run.status.state === "dispatched" || run.status.state === "in_progress") { + run.scenarios.forEach((s) => active.add(s.id)); + } + } + return active; +} From 285d8a38a9450c657dc02795845ccab3024c3710 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 17:33:41 +0000 Subject: [PATCH 07/37] feat(modelling-runs): compute preview counts in-app, drop preview-run ask previewModellingRun now resolves Run filters in one grouped SQL statement (override snapshot -> EPC-derived via propertyTypeSql/new builtFormTypeSql -> Unknown) and intersects the matched set with plan for per-scenario already-modelled counts. The Model-backend ask shrinks to the distributor alone; the app's preview is the reference implementation of the resolution rule (ADR-0008 amended). Verified live against 814: all=340 matched / 338 already modelled in scenario 1271; B93 8SU=2 resolving House/Detached through override snapshots; Bungalow/Unknown=0. Co-Authored-By: Claude Fable 5 --- ...8-modelling-runs-filters-to-distributor.md | 21 ++-- .../modelling-runs/preview/route.ts | 8 +- src/lib/modellingRuns/server.ts | 95 +++++++++++++------ src/lib/services/epcSources.ts | 12 ++- 4 files changed, 89 insertions(+), 47 deletions(-) diff --git a/docs/adr/0008-modelling-runs-filters-to-distributor.md b/docs/adr/0008-modelling-runs-filters-to-distributor.md index dab4e11e..0fb7785b 100644 --- a/docs/adr/0008-modelling-runs-filters-to-distributor.md +++ b/docs/adr/0008-modelling-runs-filters-to-distributor.md @@ -44,11 +44,15 @@ explicit marker when trigger wiring lands. This is that wiring. properties.** Resolution precedence: landlord-override fact → EPC-derived value (new-approach EPC graph, lodged over predicted, or legacy property-row columns) → **Unknown**. The app never re-implements this rule. -- **Preview counts come from a backend dry-run/count endpoint** taking the - same filters through the same resolution code path as the real run — the - preview is a promise, not an estimate. The response includes per-scenario - already-modelled counts so the UI can flag re-modelling ("120 of 214 will - be re-modelled"). Plan arithmetic is exact: one plan per +- **Preview counts are computed in-app** (amended 2026-07-06; originally a + backend dry-run endpoint): one grouped SQL statement resolves the filters — + override snapshot → EPC-derived (`propertyTypeSql`/`builtFormTypeSql` in + epcSources: new-approach graph, lodged over predicted, RdSAP codes mapped; + legacy property-row columns) → Unknown — and intersects the matched set + with `plan` for per-scenario already-modelled counts ("120 of 214 will be + re-modelled"). Both the app and the distributor read this database and + implement this documented rule; divergence is a bug in whichever side moved + without the other. Plan arithmetic is exact: one plan per (scenario, property) per run. - **Filter options are resolution-free in the app**: the postcode multi-select is one `SELECT DISTINCT postcode` per portfolio; type/built-form options are @@ -89,10 +93,11 @@ explicit marker when trigger wiring lands. This is that wiring. ## Consequences -- Two backend asks must land with the distributor: accept an app-created +- One backend ask must land with the distributor: accept an app-created `task_id` and attach execution subtasks to it (rather than creating its own - task, as the current `plan_engine` entrypoint does), and expose the - dry-run/count endpoint sharing the resolution code path. + task, as the current `plan_engine` entrypoint does). Its filter resolution + must match the rule above — the app's preview is the reference + implementation. - The preview's already-modelled callout and matched counts are only as fresh as trigger time; a property added between preview and trigger is picked up by the run (filters re-resolve) — the run row records the previewed count so diff --git a/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts b/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts index ee8c768f..4817d91c 100644 --- a/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts +++ b/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts @@ -47,14 +47,8 @@ export async function POST( portfolioId, scenarioIds: [...new Set(body.scenarioIds)], filters: normalised.filters, - sessionToken: - request.cookies.get("__Secure-next-auth.session-token")?.value ?? - request.cookies.get("next-auth.session-token")?.value, }); - if (!result.ok) { - return NextResponse.json({ error: result.message }, { status: result.status }); - } - return NextResponse.json(result.data); + return NextResponse.json(result); } catch (err) { console.error("POST /modelling-runs/preview error:", err); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); diff --git a/src/lib/modellingRuns/server.ts b/src/lib/modellingRuns/server.ts index b20a1940..62a74337 100644 --- a/src/lib/modellingRuns/server.ts +++ b/src/lib/modellingRuns/server.ts @@ -11,6 +11,7 @@ import { parseRunConfig, selectionSummary, } from "./model"; +import { builtFormTypeSql, propertyTypeSql } from "@/lib/services/epcSources"; // The config subtask that makes a run's request durable (ADR-0008); execution // subtasks attached by the distributor carry other service values. @@ -19,8 +20,6 @@ export const RUN_CONFIG_SERVICE = "modelling_run_config"; // Distributor entry point on the Model backend. It fans the run out to // workers, attaching execution subtasks to the task_id we pass (ADR-0008). const DISTRIBUTOR_ENDPOINT = "/v1/plan/trigger-run"; -// Dry-run sibling: same filter resolution, counts only. -const PREVIEW_ENDPOINT = "/v1/plan/preview-run"; type BackendResult = { ok: true; data: T } | { ok: false; status: number; message: string }; @@ -121,40 +120,74 @@ export interface PreviewCounts { perScenario: { scenarioId: string; alreadyModelled: number }[]; } -/** Proxy the backend dry-run: same resolution code path as the real run. */ +/** + * Count the properties a Run filter matches, plus how many of them already + * hold plans per selected scenario. Resolution precedence per ADR-0008: + * landlord-override snapshot → EPC-derived (new-approach graph, lodged over + * predicted, or legacy property-row columns) → Unknown. The distributor + * implements the same documented rule; both read this database. + * One grouped, portfolio-scoped statement — no per-scenario probes. + */ export async function previewModellingRun(args: { portfolioId: string; scenarioIds: string[]; filters: RunFilters; - sessionToken: string | undefined; -}): Promise> { - const { configInputs } = buildRunRecord({ - ...args, - previewedPropertyCount: 0, - triggeredBy: "preview", - }); - const res = await callBackend<{ - matched_properties: number; - per_scenario?: { scenario_id: number; already_modelled: number }[]; - }>( - PREVIEW_ENDPOINT, - { - portfolio_id: configInputs.portfolio_id, - scenario_ids: configInputs.scenario_ids, - filters: configInputs.filters, - }, - args.sessionToken, - ); - if (!res.ok) return res; +}): Promise { + const pid = BigInt(args.portfolioId); + const inList = (values: (string | number)[]) => + sql.join(values.map((v) => sql`${v}`), sql`, `); + const conditions = [sql`TRUE`]; + if (args.filters.postcodes) { + conditions.push(sql`resolved.postcode IN (${inList(args.filters.postcodes)})`); + } + if (args.filters.propertyTypes) { + conditions.push(sql`resolved.ptype IN (${inList(args.filters.propertyTypes)})`); + } + if (args.filters.builtForms) { + conditions.push(sql`resolved.bform IN (${inList(args.filters.builtForms)})`); + } + + const result = await db.execute<{ + matched: number; + per_scenario: { scenario_id: string; n: number }[] | null; + }>(sql` + WITH resolved AS ( + SELECT p.id, p.postcode, + COALESCE(pot.override_value, ${propertyTypeSql}, 'Unknown') AS ptype, + COALESCE(pobf.override_value, ${builtFormTypeSql}, 'Unknown') AS bform + FROM property p + LEFT JOIN property_overrides pot ON pot.property_id = p.id + AND pot.override_component = 'property_type' AND pot.building_part = 0 + LEFT JOIN property_overrides pobf ON pobf.property_id = p.id + AND pobf.override_component = 'built_form_type' AND pobf.building_part = 0 + LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged' + LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted' + WHERE p.portfolio_id = ${pid} AND p.marked_for_deletion = false + ), + matched AS ( + SELECT resolved.id FROM resolved + WHERE ${sql.join(conditions, sql` AND `)} + ), + already AS ( + SELECT scenario_id, COUNT(DISTINCT property_id)::int AS n + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id IN (${inList(args.scenarioIds.map(Number))}) + AND property_id IN (SELECT id FROM matched) + GROUP BY scenario_id + ) + SELECT + (SELECT COUNT(*)::int FROM matched) AS matched, + (SELECT json_agg(json_build_object('scenario_id', scenario_id::text, 'n', n)) FROM already) AS per_scenario + `); + + const row = result.rows[0]; return { - ok: true, - data: { - matchedProperties: res.data.matched_properties, - perScenario: (res.data.per_scenario ?? []).map((p) => ({ - scenarioId: String(p.scenario_id), - alreadyModelled: p.already_modelled, - })), - }, + matchedProperties: row?.matched ?? 0, + perScenario: (row?.per_scenario ?? []).map((p) => ({ + scenarioId: p.scenario_id, + alreadyModelled: p.n, + })), }; } diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index 8dd28206..3a75bfd9 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -211,7 +211,7 @@ export const PROPERTY_TYPE_LABELS: Record = { "3": "Maisonette", "4": "Park home", }; -const BUILT_FORM_LABELS: Record = { +export const BUILT_FORM_LABELS: Record = { "1": "Detached", "2": "Semi-Detached", "3": "End-Terrace", @@ -273,6 +273,16 @@ export const propertyTypeSql = sql`CASE ELSE p.property_type END`; +/** + * Built-form label. New: epc_property (lodged, else predicted) — RdSAP codes + * mapped via BUILT_FORM_LABELS, text passed through, mirroring + * resolvePropertyDescriptors. Legacy: property.built_form. + */ +export const builtFormTypeSql = sql`CASE + WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${codeLabelCaseSql(sql`epl.built_form`, BUILT_FORM_LABELS)} ELSE ${codeLabelCaseSql(sql`epp.built_form`, BUILT_FORM_LABELS)} END) + ELSE p.built_form +END`; + /** * The main building part's construction age band for one epc_property alias, as * a correlated scalar subquery — properties can also have extension parts, so a From 10888e3fff9d7391ae0f485852ee30de2d913912 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 17:40:09 +0000 Subject: [PATCH 08/37] refactor(modelling-runs): minimal dispatch payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distributor gets {task_id, portfolio_id, scenario_ids, filters} only — previewed count and triggered_by are app provenance, already durable on the config subtask keyed by the same task_id. Co-Authored-By: Claude Fable 5 --- src/lib/modellingRuns/server.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/modellingRuns/server.ts b/src/lib/modellingRuns/server.ts index 62a74337..d5ef3984 100644 --- a/src/lib/modellingRuns/server.ts +++ b/src/lib/modellingRuns/server.ts @@ -101,9 +101,16 @@ export async function triggerModellingRun(args: { inputs: JSON.stringify(record.configInputs), }); + // Minimal dispatch payload — previewed count and triggered_by are app + // provenance, already durable on the config subtask keyed by this task_id. const dispatch = await callBackend( DISTRIBUTOR_ENDPOINT, - { task_id: task.id, ...record.configInputs }, + { + task_id: task.id, + portfolio_id: record.configInputs.portfolio_id, + scenario_ids: record.configInputs.scenario_ids, + filters: record.configInputs.filters, + }, args.sessionToken, ); if (!dispatch.ok) { From 23fba68727869d9980cc3192175ae654adabd0b6 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 6 Jul 2026 18:41:19 +0000 Subject: [PATCH 09/37] feat(portfolio): edit address/postcode on unmatched properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each unmatched-property row now opens a small dialog to correct its address and postcode, saved via a new endpoint. UPRN matching (the address search) is left for a later change — this only fixes the address text so the record is right before matching. - PATCH /api/portfolio/[portfolioId]/properties/[propertyId]: auth + zod, updates address/postcode scoped by (property, portfolio). - properties/client.ts: useUpdateAddress() mutation. - EditAddress: the edit dialog; on save the page re-fetches (router.refresh) so the row reflects the new values. - UnmatchedProperties: row action swapped from the placeholder to the live Edit dialog; portfolioId threaded through from the page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../properties/[propertyId]/route.ts | 64 ++++++++++ src/app/portfolio/[slug]/(portfolio)/page.tsx | 2 +- .../[slug]/components/EditAddress.tsx | 120 ++++++++++++++++++ .../[slug]/components/UnmatchedProperties.tsx | 25 ++-- src/lib/properties/client.ts | 31 +++++ 5 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts create mode 100644 src/app/portfolio/[slug]/components/EditAddress.tsx create mode 100644 src/lib/properties/client.ts diff --git a/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts b/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts new file mode 100644 index 00000000..4bf3ecbe --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { db } from "@/app/db/db"; +import { property } from "@/app/db/schema/property"; +import { and, eq } from "drizzle-orm"; + +// Edit an unmatched property's address / postcode. Keyed by (property, +// portfolio) so a caller can only touch a property in a portfolio they +// addressed. UPRN assignment (via the address search) is deliberately out of +// scope here — this only corrects the address text. +const patchSchema = z + .object({ + address: z.string().trim().optional(), + postcode: z.string().trim().optional(), + }) + .refine((v) => v.address !== undefined || v.postcode !== undefined, { + message: "Nothing to update", + }); + +export async function PATCH( + request: NextRequest, + props: { params: Promise<{ portfolioId: string; propertyId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const { portfolioId, propertyId } = await props.params; + if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(propertyId)) { + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + } + + const parsed = patchSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + { status: 400 }, + ); + } + const { address, postcode } = parsed.data; + + const updated = await db + .update(property) + .set({ + ...(address !== undefined ? { address } : {}), + ...(postcode !== undefined ? { postcode } : {}), + updatedAt: new Date(), + }) + .where( + and( + eq(property.id, BigInt(propertyId)), + eq(property.portfolioId, BigInt(portfolioId)), + ), + ) + .returning({ id: property.id }); + + if (updated.length === 0) { + return NextResponse.json({ error: "Property not found" }, { status: 404 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index d4514980..9af81277 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -17,7 +17,7 @@ export default async function Page(props: { return ( <> - + ); diff --git a/src/app/portfolio/[slug]/components/EditAddress.tsx b/src/app/portfolio/[slug]/components/EditAddress.tsx new file mode 100644 index 00000000..087d1252 --- /dev/null +++ b/src/app/portfolio/[slug]/components/EditAddress.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { PencilSquareIcon } from "@heroicons/react/24/outline"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { useUpdateAddress } from "@/lib/properties/client"; +import type { UnmatchedProperty } from "@/lib/properties/unmatched"; + +// Row action for an unmatched property: correct its address / postcode. UPRN +// matching is a later step — this just saves the text. On success the page +// re-fetches so the row shows the updated values. +export default function EditAddress({ + property, + portfolioId, +}: { + property: UnmatchedProperty; + portfolioId: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [address, setAddress] = useState(property.address ?? ""); + const [postcode, setPostcode] = useState(property.postcode ?? ""); + + const update = useUpdateAddress(portfolioId, property.id); + + function onSave() { + update.mutate( + { address: address.trim(), postcode: postcode.trim() }, + { + onSuccess: () => { + setOpen(false); + router.refresh(); + }, + }, + ); + } + + return ( + <> + + + + + + Edit address + + Correct the address and postcode for this property. + + + +
+
+ + setAddress(e.target.value)} + placeholder="e.g. 20 Brenchley Road" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
+
+ + setPostcode(e.target.value)} + placeholder="e.g. BR5 2TD" + onKeyDown={(e) => { + if (e.key === "Enter" && !update.isPending) onSave(); + }} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
+
+ + + {update.error && ( +

+ {update.error.message} +

+ )} + + +
+
+
+ + ); +} diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx index 9d6ef33e..60a1dcdf 100644 --- a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx +++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx @@ -1,18 +1,17 @@ -import { - ExclamationTriangleIcon, - MagnifyingGlassIcon, -} from "@heroicons/react/24/outline"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import type { UnmatchedProperty } from "@/lib/properties/unmatched"; +import EditAddress from "./EditAddress"; // The "needs attention" workspace that separates properties with no UPRN from -// the matched portfolio table. Read-only for now; the per-row action is where -// the advanced ARA search (edit address/postcode → search UPRN) will hang off. -// Renders nothing when every property is matched, so a healthy portfolio stays -// uncluttered. +// the matched portfolio table. Each row lets the user correct the address / +// postcode (UPRN matching is a later step). Renders nothing when every property +// is matched, so a healthy portfolio stays uncluttered. export default function UnmatchedProperties({ properties, + portfolioId, }: { properties: UnmatchedProperty[]; + portfolioId: string; }) { if (properties.length === 0) return null; @@ -67,15 +66,7 @@ export default function UnmatchedProperties({ {p.postcode ?? }

- +
))} diff --git a/src/lib/properties/client.ts b/src/lib/properties/client.ts new file mode 100644 index 00000000..44848424 --- /dev/null +++ b/src/lib/properties/client.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; + +async function parseError(res: Response, fallback: string): Promise { + const body = await res.json().catch(() => ({})); + return new Error(body?.error ?? fallback); +} + +export interface UpdateAddressInput { + address: string; + postcode: string; +} + +// Save an edited address / postcode onto a property. +export function useUpdateAddress(portfolioId: string, propertyId: string) { + return useMutation<{ ok: true }, Error, UpdateAddressInput>({ + mutationFn: async (input) => { + const res = await fetch( + `/api/portfolio/${portfolioId}/properties/${propertyId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (!res.ok) throw await parseError(res, "Failed to save address."); + return res.json(); + }, + }); +} From 369485058bfdff993ef053591a5d05c0d4651101 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 10:38:28 +0000 Subject: [PATCH 10/37] fix(building-passport): handle properties with no UPRN Opening the building passport of an unmatched property 500'd: the whole page is UPRN-keyed and getSpatialData did BigInt(propertyMeta.uprn) with a null uprn ("Cannot convert null to a BigInt"). Since a property with no UPRN has no spatial data, installed measures, or EPC to show, render a clear "not matched yet" state with a link back to the portfolio instead of crashing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../building-passport/[propertyId]/page.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx index 92c12eed..ed2080be 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx @@ -6,8 +6,10 @@ import { SparklesIcon, BuildingOfficeIcon, ShieldCheckIcon, + ExclamationTriangleIcon, } from "@heroicons/react/24/outline"; import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid"; +import Link from "next/link"; import { getPropertyMeta, getConditionReport, @@ -67,6 +69,43 @@ export default async function BuildingPassportHome(props: { }) { const params = await props.params; const propertyMeta = await getPropertyMeta(params.propertyId); + + // A property with no UPRN isn't matched to an Ordnance Survey address yet, so + // the whole UPRN-keyed passport (spatial data, installed measures, EPC) has + // nothing to show — and getSpatialData(BigInt(null)) would 500. Surface a + // clear "not matched" state and link back to the portfolio instead. + if (!propertyMeta.uprn) { + return ( +
+
+ +

+ Not matched to a UPRN yet +

+

+ {propertyMeta.address ? ( + <> + {propertyMeta.address}{" "} + hasn't{" "} + + ) : ( + "This property hasn't " + )} + been matched to an Ordnance Survey address, so its building passport + isn't available yet. Match it to a UPRN from the portfolio to + unlock the passport. +

+ + Back to portfolio + +
+
+ ); + } + const conditionReport = await getConditionReport(params.propertyId); const spatial = await getSpatialData(propertyMeta.uprn); const installedMeasures = await getInstalledMeasuresByUprn(Number(propertyMeta.uprn)); From 83117e90c5d09902fe90b7cd8470103299b7e4ee Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 10:46:29 +0000 Subject: [PATCH 11/37] feat(modelling-runs): run config on tasks.inputs; backend contract updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The run's config now lives on the task row's new `inputs` column (migration PR #361) instead of a config subtask — sub_tasks belong entirely to the distributor's execution work. - Distributor path corrected to /v1/modelling/trigger-run. - Sub-task granularity is batches, not properties: history shows progress as a percentage under the status chip; the Plans column always shows the plan count (batch counts are never comparable to property/plan numbers). - ADR-0008 amended accordingly. Live verification of the task write waits for PR #361 to be applied. Co-Authored-By: Claude Fable 5 --- ...8-modelling-runs-filters-to-distributor.md | 29 +++++++----- src/app/db/schema/tasks/tasks.ts | 5 +++ .../modelling/run/RunModellingClient.tsx | 31 ++++++------- src/lib/modellingRuns/model.test.ts | 18 +++++--- src/lib/modellingRuns/model.ts | 26 +++++++---- src/lib/modellingRuns/server.ts | 45 +++++++------------ 6 files changed, 84 insertions(+), 70 deletions(-) diff --git a/docs/adr/0008-modelling-runs-filters-to-distributor.md b/docs/adr/0008-modelling-runs-filters-to-distributor.md index 0fb7785b..bbe0985c 100644 --- a/docs/adr/0008-modelling-runs-filters-to-distributor.md +++ b/docs/adr/0008-modelling-runs-filters-to-distributor.md @@ -26,17 +26,22 @@ explicit marker when trigger wiring lands. This is that wiring. - **A Modelling run is recorded as an app-created task** — the BulkUpload trigger convention (`triggerAddressMatching`): Next.js inserts the `tasks` row (service `modelling_run`, `source_id` = portfolio, status `waiting`) - plus one config `sub_task` whose `inputs` JSON carries - `{scenario_ids, filters, previewed_property_count, triggered_by}`, then - passes `task_id` to the distributor, which attaches its execution subtasks - to the same task. **No new table, no migration.** Run history and the - per-scenario in-flight badge read the portfolio's own `modelling_run` tasks - (portfolio-scoped, a handful of rows) and parse the app-authored inputs - JSON; execution progress ("61 of 104 ready") falls out of the existing - subtask-count summary pattern. -- **The distributor receives `{task_id, portfolio_id, scenario_ids, filters}`** - — filters, never property-id lists. The payload stays tiny at any portfolio - size; the config subtask is the durable copy of the same request. + whose **`inputs` column** (added for this — mirrors `sub_task.inputs`) + carries `{portfolio_id, scenario_ids, filters, previewed_property_count, + triggered_by}` as JSON, then passes `task_id` to the distributor, which + attaches its execution sub-tasks to the same task. **One new column, no new + table.** Run history and the per-scenario in-flight badge read the + portfolio's own `modelling_run` tasks (portfolio-scoped, a handful of rows) + and parse the app-authored inputs JSON; execution progress falls out of the + existing subtask-count summary pattern. Sub-task granularity is the + distributor's **batches**, so progress is shown as a percentage — batch + counts are internally consistent but never comparable to property or plan + numbers. +- **The distributor (`POST /v1/modelling/trigger-run`) receives + `{task_id, portfolio_id, scenario_ids, filters}`** — filters, never + property-id lists. The payload stays tiny at any portfolio size; the task's + `inputs` is the durable copy of the same request (plus provenance the + distributor doesn't need). - **Dispatch failure marks the task `failed`** (an improvement on the bulk-upload flow, which leaves failed triggers at `waiting`), so history never shows a ghost run as pending. @@ -63,7 +68,7 @@ explicit marker when trigger wiring lands. This is that wiring. run and the execution share one record by construction. Scenario badges gain a third derived state — "Modelling in progress" — and a per-portfolio run history lists each run's filters, counts, initiator and derived status - with subtask-count progress. + with batch-based percentage progress. - **Concurrent runs warn, never block**: triggering a scenario with a run in flight shows who started it and when, but proceeds. Plans append and latest-per-property wins on read, so overlap is safe; a hard lock could leak diff --git a/src/app/db/schema/tasks/tasks.ts b/src/app/db/schema/tasks/tasks.ts index badce0d0..e778d0d7 100644 --- a/src/app/db/schema/tasks/tasks.ts +++ b/src/app/db/schema/tasks/tasks.ts @@ -18,6 +18,11 @@ export const tasks = pgTable("tasks", { sourceId: text("source_id"), // identifier for the source (e.g., portfolio_id value) + // The task's request inputs, mirroring sub_task.inputs (JSON as text; could + // later change to JSONB). For a modelling run this is the run's config — + // portfolio id, scenario ids, filters (ADR-0008). + inputs: text("inputs"), + updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true }) .defaultNow() .$onUpdate(() => new Date()) diff --git a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx index 34202925..68b6c691 100644 --- a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx +++ b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx @@ -434,21 +434,7 @@ export default function RunModellingClient({ {r.previewedPropertyCount.toLocaleString()} - {r.status.state === "in_progress" ? ( - <> - - {r.status.ready.toLocaleString()} of {r.status.total.toLocaleString()} ready - - - - - - ) : ( - r.planCount.toLocaleString() - )} + {r.planCount.toLocaleString()} {STATUS_LABELS[r.status.state]} + {/* Work is chunked into batches, so show a percentage — + the counts never line up with property/plan numbers */} + {r.status.state === "in_progress" && ( + <> + + {Math.round((100 * r.status.ready) / Math.max(1, r.status.total))}% done + + + + + + )} ))} diff --git a/src/lib/modellingRuns/model.test.ts b/src/lib/modellingRuns/model.test.ts index 656a72fd..3767388f 100644 --- a/src/lib/modellingRuns/model.test.ts +++ b/src/lib/modellingRuns/model.test.ts @@ -44,7 +44,7 @@ describe("normaliseRunFilters", () => { }); describe("buildRunRecord", () => { - it("produces the app-authored task row and config-subtask inputs (ADR-0008)", () => { + it("produces the app-authored task row carrying the run config as its inputs (ADR-0008)", () => { const record = buildRunRecord({ portfolioId: "814", scenarioIds: ["12", "34"], @@ -58,27 +58,33 @@ describe("buildRunRecord", () => { source: "portfolio_id", sourceId: "814", status: "waiting", + inputs: JSON.stringify({ + portfolio_id: 814, + scenario_ids: [12, 34], + filters: { postcodes: ["B93 8SU"], property_types: ["House"] }, + previewed_property_count: 214, + triggered_by: "khalim@domna.homes", + }), }); - expect(record.configInputs).toEqual({ + // What the distributor receives — provenance fields stay on the task only + expect(record.dispatchPayload).toEqual({ portfolio_id: 814, scenario_ids: [12, 34], filters: { postcodes: ["B93 8SU"], property_types: ["House"] }, - previewed_property_count: 214, - triggered_by: "khalim@domna.homes", }); }); }); describe("parseRunConfig", () => { it("round-trips a stored run record back to the app's shape", () => { - const { configInputs } = buildRunRecord({ + const { task } = buildRunRecord({ portfolioId: "814", scenarioIds: ["12"], filters: { builtForms: ["Detached", "Unknown"] }, previewedPropertyCount: 57, triggeredBy: "priya@domna.homes", }); - expect(parseRunConfig(JSON.stringify(configInputs))).toEqual({ + expect(parseRunConfig(task.inputs)).toEqual({ scenarioIds: ["12"], filters: { builtForms: ["Detached", "Unknown"] }, previewedPropertyCount: 57, diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts index c00fe73a..973bf2b5 100644 --- a/src/lib/modellingRuns/model.ts +++ b/src/lib/modellingRuns/model.ts @@ -168,9 +168,9 @@ export interface RunConfigInputs { } /** - * The rows a Modelling run persists (ADR-0008): an app-authored task (the - * BulkUpload trigger convention) plus the config-subtask inputs that make the - * run's request durable and history-readable. + * The row a Modelling run persists (ADR-0008): an app-authored task whose + * `inputs` carries the run's config — the durable, history-readable record. + * Sub-tasks are the distributor's: per-property execution work. */ export function buildRunRecord(req: RunRequest): { task: { @@ -179,13 +179,22 @@ export function buildRunRecord(req: RunRequest): { source: "portfolio_id"; sourceId: string; status: "waiting"; + inputs: string; }; - configInputs: RunConfigInputs; + /** What the distributor receives — provenance fields stay on the task. */ + dispatchPayload: Omit; } { const filters: RunConfigInputs["filters"] = {}; if (req.filters.postcodes) filters.postcodes = req.filters.postcodes; if (req.filters.propertyTypes) filters.property_types = req.filters.propertyTypes; if (req.filters.builtForms) filters.built_forms = req.filters.builtForms; + const configInputs: RunConfigInputs = { + portfolio_id: Number(req.portfolioId), + scenario_ids: req.scenarioIds.map(Number), + filters, + previewed_property_count: req.previewedPropertyCount, + triggered_by: req.triggeredBy, + }; return { task: { taskSource: `Modelling run – ${req.scenarioIds.length} scenario${req.scenarioIds.length === 1 ? "" : "s"}`, @@ -193,13 +202,12 @@ export function buildRunRecord(req: RunRequest): { source: "portfolio_id", sourceId: req.portfolioId, status: "waiting", + inputs: JSON.stringify(configInputs), }, - configInputs: { - portfolio_id: Number(req.portfolioId), - scenario_ids: req.scenarioIds.map(Number), + dispatchPayload: { + portfolio_id: configInputs.portfolio_id, + scenario_ids: configInputs.scenario_ids, filters, - previewed_property_count: req.previewedPropertyCount, - triggered_by: req.triggeredBy, }, }; } diff --git a/src/lib/modellingRuns/server.ts b/src/lib/modellingRuns/server.ts index d5ef3984..63f03c31 100644 --- a/src/lib/modellingRuns/server.ts +++ b/src/lib/modellingRuns/server.ts @@ -13,13 +13,11 @@ import { } from "./model"; import { builtFormTypeSql, propertyTypeSql } from "@/lib/services/epcSources"; -// The config subtask that makes a run's request durable (ADR-0008); execution -// subtasks attached by the distributor carry other service values. -export const RUN_CONFIG_SERVICE = "modelling_run_config"; - // Distributor entry point on the Model backend. It fans the run out to -// workers, attaching execution subtasks to the task_id we pass (ADR-0008). -const DISTRIBUTOR_ENDPOINT = "/v1/plan/trigger-run"; +// workers, attaching execution sub-tasks to the task_id we pass (ADR-0008). +// NOTE: sub-task granularity is BATCHES, not properties — progress counts are +// internally consistent but never comparable to the previewed property count. +const DISTRIBUTOR_ENDPOINT = "/v1/modelling/trigger-run"; type BackendResult = { ok: true; data: T } | { ok: false; status: number; message: string }; @@ -62,8 +60,9 @@ export type TriggerRunOutcome = | { kind: "dispatch_failed"; status: number; message: string }; /** - * Record + dispatch a Modelling run: app-authored task and config subtask - * (BulkUpload trigger convention), then hand task_id to the distributor. A + * Record + dispatch a Modelling run: one app-authored task whose `inputs` + * carries the run's config (BulkUpload trigger convention), then hand + * task_id to the distributor — sub_tasks are its, one per property. A * failed dispatch marks the task failed so history never shows a ghost run * as pending (ADR-0008). */ @@ -94,23 +93,12 @@ export async function triggerModellingRun(args: { .insert(tasks) .values({ ...record.task, jobStarted: now }) .returning(); - await db.insert(subTasks).values({ - taskId: task.id, - status: "waiting", - service: RUN_CONFIG_SERVICE, - inputs: JSON.stringify(record.configInputs), - }); // Minimal dispatch payload — previewed count and triggered_by are app - // provenance, already durable on the config subtask keyed by this task_id. + // provenance, already durable on the task's inputs. const dispatch = await callBackend( DISTRIBUTOR_ENDPOINT, - { - task_id: task.id, - portfolio_id: record.configInputs.portfolio_id, - scenario_ids: record.configInputs.scenario_ids, - filters: record.configInputs.filters, - }, + { task_id: task.id, ...record.dispatchPayload }, args.sessionToken, ); if (!dispatch.ok) { @@ -210,9 +198,10 @@ export interface RunHistoryRow { } /** - * Run history for a portfolio: its modelling_run tasks with app-authored - * config parsed back out and progress from execution-subtask counts. One - * grouped query — portfolio-scoped, never per-run probes. + * Run history for a portfolio: its modelling_run tasks with the app-authored + * config parsed from task inputs and progress from sub-task counts (one + * sub-task per property, distributor-attached). One grouped query — + * portfolio-scoped, never per-run probes. */ export async function listModellingRuns( portfolioId: string, @@ -224,10 +213,10 @@ export async function listModellingRuns( jobStarted: tasks.jobStarted, jobCompleted: tasks.jobCompleted, status: tasks.status, - configInputs: sql`max(${subTasks.inputs}) filter (where ${subTasks.service} = ${RUN_CONFIG_SERVICE})`, - total: sql`count(${subTasks.id}) filter (where ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE})::int`, - completed: sql`count(case when lower(${subTasks.status}) in ('completed','complete') and ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE} then 1 end)::int`, - failed: sql`count(case when lower(${subTasks.status}) in ('failed','failure','error') and ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE} then 1 end)::int`, + configInputs: tasks.inputs, + total: sql`count(${subTasks.id})::int`, + completed: sql`count(case when lower(${subTasks.status}) in ('completed','complete') then 1 end)::int`, + failed: sql`count(case when lower(${subTasks.status}) in ('failed','failure','error') then 1 end)::int`, }) .from(tasks) .leftJoin(subTasks, eq(subTasks.taskId, tasks.id)) From f1cef2e6434acaa2e69abb992cc620a8c96f4787 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 11:05:40 +0000 Subject: [PATCH 12/37] feat(portfolio): enable the "Bulk excel import" entry point The Add-properties menu had "Bulk excel import" disabled behind a "Soon" badge. The bulk-upload flow exists (/portfolio/[id]/bulk-upload), so make the item clickable and route to it, matching the "Search by postcode" item. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../portfolio/[slug]/components/PropertyTable.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 8fd7aed1..6d452e42 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -654,21 +654,20 @@ export default function PropertyTable({ + router.push(`/portfolio/${portfolioId}/bulk-upload`) + } > - + - + Bulk excel import Upload a spreadsheet of addresses - - Soon - From 7ac632a7aa3445920160d1b4d04e32d3ba888df1 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 11:17:48 +0000 Subject: [PATCH 13/37] feat(portfolio): move unmatched properties into a "Needs attention" tab Reworks the separation from a card stacked above the table into a proper second tab, and takes unmatched properties out of the main list entirely: - PortfolioTabs: "Properties" | "Needs attention" tab shell with a count badge on the second tab. Both panels stay mounted (hidden with CSS) so the main table keeps its filter/pagination state across tab switches. - getProperties / getPropertiesCount now exclude uprn IS NULL, so unmatched properties no longer appear (or count) in the main table until resolved. (Both functions are only used by the main table's /api/properties route.) - UnmatchedProperties: lives in the tab; shows an "all matched" empty state instead of rendering nothing, and copy updated for the edit-only scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/portfolio/[slug]/(portfolio)/page.tsx | 12 ++- .../[slug]/components/PortfolioTabs.tsx | 74 +++++++++++++++++++ .../[slug]/components/UnmatchedProperties.tsx | 35 ++++++--- src/app/portfolio/[slug]/utils.ts | 6 ++ 4 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 src/app/portfolio/[slug]/components/PortfolioTabs.tsx diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index 9af81277..24bd0aba 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -1,5 +1,6 @@ import PropertyTable from "../components/PropertyTable"; import UnmatchedProperties from "../components/UnmatchedProperties"; +import PortfolioTabs from "../components/PortfolioTabs"; import { getUnmatchedProperties } from "@/lib/properties/unmatched"; export const revalidate = 60; @@ -16,9 +17,12 @@ export default async function Page(props: { const unmatched = await getUnmatchedProperties(portfolioId); return ( - <> - - - + } + needsAttention={ + + } + /> ); } diff --git a/src/app/portfolio/[slug]/components/PortfolioTabs.tsx b/src/app/portfolio/[slug]/components/PortfolioTabs.tsx new file mode 100644 index 00000000..59aba28d --- /dev/null +++ b/src/app/portfolio/[slug]/components/PortfolioTabs.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; + +// Two-tab shell for the portfolio landing view: the main properties table, and +// a "Needs attention" tab for properties that aren't matched to a UPRN yet. +// Both panels stay mounted (hidden with CSS) so the table keeps its filter / +// pagination state when the user flicks between tabs. +export default function PortfolioTabs({ + needsAttentionCount, + properties, + needsAttention, +}: { + needsAttentionCount: number; + properties: React.ReactNode; + needsAttention: React.ReactNode; +}) { + const [tab, setTab] = useState<"properties" | "needs-attention">("properties"); + + return ( +
+
+ setTab("properties")} + > + Properties + + setTab("needs-attention")} + > + + Needs attention + {needsAttentionCount > 0 && ( + + {needsAttentionCount} + + )} + +
+ +
{properties}
+
+ {needsAttention} +
+
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx index 60a1dcdf..adaf4e15 100644 --- a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx +++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx @@ -1,11 +1,13 @@ -import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +import { + ExclamationTriangleIcon, + CheckCircleIcon, +} from "@heroicons/react/24/outline"; import type { UnmatchedProperty } from "@/lib/properties/unmatched"; import EditAddress from "./EditAddress"; -// The "needs attention" workspace that separates properties with no UPRN from -// the matched portfolio table. Each row lets the user correct the address / -// postcode (UPRN matching is a later step). Renders nothing when every property -// is matched, so a healthy portfolio stays uncluttered. +// The "Needs attention" tab: properties with no UPRN, separated out of the main +// table until they're matched. Each row lets the user correct the address / +// postcode (UPRN matching is a later step). export default function UnmatchedProperties({ properties, portfolioId, @@ -13,14 +15,26 @@ export default function UnmatchedProperties({ properties: UnmatchedProperty[]; portfolioId: string; }) { - if (properties.length === 0) return null; - const count = properties.length; + if (count === 0) { + return ( +
+ +

+ All properties are matched +

+

+ Every property has a UPRN — nothing needs attention. +

+
+ ); + } + return (
@@ -35,8 +49,9 @@ export default function UnmatchedProperties({

{count === 1 ? "This property" : "These properties"} couldn't be - matched to an Ordnance Survey address automatically. Check the address - and postcode, then run an advanced search to find the right match. + matched to an Ordnance Survey address automatically. Correct the + address and postcode so {count === 1 ? "it's" : "they're"} ready to + match — they stay out of the main list until resolved.

diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 6014cc25..0eb1b88e 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -739,6 +739,9 @@ export async function getPropertiesCount( ${epcJoins} ${planJoin} WHERE p.portfolio_id = ${portfolioId} + -- Unmatched (no-UPRN) properties live in the "Needs attention" tab until + -- resolved, so they're excluded from the main table + its count. + AND p.uprn IS NOT NULL ${combinedWhere} `); @@ -831,6 +834,9 @@ export async function getProperties( ON epc.property_id = p.id ${newApproachJoins} WHERE p.portfolio_id = ${portfolioId} + -- Unmatched (no-UPRN) properties live in the "Needs attention" tab until + -- resolved, so they're excluded from the main table. + AND p.uprn IS NOT NULL ${combinedWhere} LIMIT ${limit} OFFSET ${offset}; `); From aab3984b5c5384d68cc6381588af43c453244123 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 11:21:44 +0000 Subject: [PATCH 14/37] copy: reword unmatched-properties help text to AraAddressMatcher Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/portfolio/[slug]/components/UnmatchedProperties.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx index adaf4e15..a1f5d03d 100644 --- a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx +++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx @@ -49,9 +49,9 @@ export default function UnmatchedProperties({

{count === 1 ? "This property" : "These properties"} couldn't be - matched to an Ordnance Survey address automatically. Correct the - address and postcode so {count === 1 ? "it's" : "they're"} ready to - match — they stay out of the main list until resolved. + matched by AraAddressMatcher automatically. Double check the{" "} + {count === 1 ? "address" : "addresses"} and Ara will try again with + AraAddressMatcherPlus.

From 3f8920acd00563c983db91bcf4364223c1f1c4fa Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 11:33:01 +0000 Subject: [PATCH 15/37] feat(portfolio): match unmatched properties to a residential UPRN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the "Needs attention" tab into an actionable per-property match flow: edit the address, confirm the postcode, search Ordnance Survey, and pick from the RESIDENTIAL addresses in that postcode. Every candidate is a real OS address, so picking one assigns a guaranteed valid, residential UPRN — the property then leaves the tab and joins the main table. - MatchAddress dialog: postcode search + residential-only candidate list (non-residential filtered out; already-in-portfolio shown but non-selectable, each row shows its UPRN). Replaces the edit-only EditAddress. - PATCH /properties/[propertyId]: assigns uprn + address + postcode (user input kept as the fallback); (portfolio_id, uprn) clash → friendly 409. - client.ts: searchAddresses() + useAssignUprn(). Reuses the existing /properties/search endpoint, which read-through caches OS Places results per postcode in postcode_search (30-day TTL) — repeat searches of the same postcode hit our DB, not the OS API (surfaced as a "from cache" hint in the dialog). No backend trigger needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../properties/[propertyId]/route.ts | 86 ++++--- .../[slug]/components/EditAddress.tsx | 120 --------- .../[slug]/components/MatchAddress.tsx | 239 ++++++++++++++++++ .../[slug]/components/UnmatchedProperties.tsx | 4 +- src/lib/properties/client.ts | 47 +++- 5 files changed, 339 insertions(+), 157 deletions(-) delete mode 100644 src/app/portfolio/[slug]/components/EditAddress.tsx create mode 100644 src/app/portfolio/[slug]/components/MatchAddress.tsx diff --git a/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts b/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts index 4bf3ecbe..16b3e28d 100644 --- a/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts @@ -6,18 +6,26 @@ import { db } from "@/app/db/db"; import { property } from "@/app/db/schema/property"; import { and, eq } from "drizzle-orm"; -// Edit an unmatched property's address / postcode. Keyed by (property, -// portfolio) so a caller can only touch a property in a portfolio they -// addressed. UPRN assignment (via the address search) is deliberately out of -// scope here — this only corrects the address text. -const patchSchema = z - .object({ - address: z.string().trim().optional(), - postcode: z.string().trim().optional(), - }) - .refine((v) => v.address !== undefined || v.postcode !== undefined, { - message: "Nothing to update", - }); +// Assign a chosen Ordnance Survey address (from the postcode search) to an +// existing property: writes the canonical uprn/address/postcode and keeps what +// the user typed as the user-inputted fallback. Keyed by (property, portfolio) +// so a caller can only touch a property in a portfolio they addressed. The +// candidate is guaranteed residential + UPRN-bearing by the picker UI. +const patchSchema = z.object({ + uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"), + address: z.string().trim().min(1, "Address is required"), + postcode: z.string().trim().min(1, "Postcode is required"), + // What the user typed in the edit fields — kept as the raw fallback, mirroring + // how the bulk finaliser stores user_inputted_* alongside the matched values. + userInputtedAddress: z.string().trim().optional(), + userInputtedPostcode: z.string().trim().optional(), +}); + +function isUniqueViolation(err: unknown): boolean { + const code = (err as { code?: string })?.code; + const message = (err as { message?: string })?.message ?? ""; + return code === "23505" || message.includes("uq_property_portfolio_uprn"); +} export async function PATCH( request: NextRequest, @@ -40,25 +48,43 @@ export async function PATCH( { status: 400 }, ); } - const { address, postcode } = parsed.data; + const { uprn, address, postcode, userInputtedAddress, userInputtedPostcode } = + parsed.data; - const updated = await db - .update(property) - .set({ - ...(address !== undefined ? { address } : {}), - ...(postcode !== undefined ? { postcode } : {}), - updatedAt: new Date(), - }) - .where( - and( - eq(property.id, BigInt(propertyId)), - eq(property.portfolioId, BigInt(portfolioId)), - ), - ) - .returning({ id: property.id }); + try { + const updated = await db + .update(property) + .set({ + uprn: BigInt(uprn), + address, + postcode, + ...(userInputtedAddress !== undefined ? { userInputtedAddress } : {}), + ...(userInputtedPostcode !== undefined ? { userInputtedPostcode } : {}), + updatedAt: new Date(), + }) + .where( + and( + eq(property.id, BigInt(propertyId)), + eq(property.portfolioId, BigInt(portfolioId)), + ), + ) + .returning({ id: property.id }); - if (updated.length === 0) { - return NextResponse.json({ error: "Property not found" }, { status: 404 }); + if (updated.length === 0) { + return NextResponse.json({ error: "Property not found" }, { status: 404 }); + } + return NextResponse.json({ ok: true }); + } catch (err) { + if (isUniqueViolation(err)) { + return NextResponse.json( + { + error: + "That UPRN is already assigned to another property in this portfolio.", + }, + { status: 409 }, + ); + } + console.error("PATCH /properties/[propertyId] error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } - return NextResponse.json({ ok: true }); } diff --git a/src/app/portfolio/[slug]/components/EditAddress.tsx b/src/app/portfolio/[slug]/components/EditAddress.tsx deleted file mode 100644 index 087d1252..00000000 --- a/src/app/portfolio/[slug]/components/EditAddress.tsx +++ /dev/null @@ -1,120 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { PencilSquareIcon } from "@heroicons/react/24/outline"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/app/shadcn_components/ui/dialog"; -import { useUpdateAddress } from "@/lib/properties/client"; -import type { UnmatchedProperty } from "@/lib/properties/unmatched"; - -// Row action for an unmatched property: correct its address / postcode. UPRN -// matching is a later step — this just saves the text. On success the page -// re-fetches so the row shows the updated values. -export default function EditAddress({ - property, - portfolioId, -}: { - property: UnmatchedProperty; - portfolioId: string; -}) { - const router = useRouter(); - const [open, setOpen] = useState(false); - const [address, setAddress] = useState(property.address ?? ""); - const [postcode, setPostcode] = useState(property.postcode ?? ""); - - const update = useUpdateAddress(portfolioId, property.id); - - function onSave() { - update.mutate( - { address: address.trim(), postcode: postcode.trim() }, - { - onSuccess: () => { - setOpen(false); - router.refresh(); - }, - }, - ); - } - - return ( - <> - - - - - - Edit address - - Correct the address and postcode for this property. - - - -
-
- - setAddress(e.target.value)} - placeholder="e.g. 20 Brenchley Road" - className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" - /> -
-
- - setPostcode(e.target.value)} - placeholder="e.g. BR5 2TD" - onKeyDown={(e) => { - if (e.key === "Enter" && !update.isPending) onSave(); - }} - className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" - /> -
-
- - - {update.error && ( -

- {update.error.message} -

- )} - - -
-
-
- - ); -} diff --git a/src/app/portfolio/[slug]/components/MatchAddress.tsx b/src/app/portfolio/[slug]/components/MatchAddress.tsx new file mode 100644 index 00000000..b446bd00 --- /dev/null +++ b/src/app/portfolio/[slug]/components/MatchAddress.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + MagnifyingGlassIcon, + CheckCircleIcon, +} from "@heroicons/react/24/outline"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { describeCandidate } from "@/lib/postcodeSearch/model"; +import { + searchAddresses, + useAssignUprn, + type SearchCandidate, +} from "@/lib/properties/client"; +import type { UnmatchedProperty } from "@/lib/properties/unmatched"; + +// Row action for an unmatched property: edit the address, confirm the postcode, +// search Ordnance Survey, and pick from the RESIDENTIAL addresses in that +// postcode. Picking assigns that candidate's UPRN — every candidate is a real +// OS address, so this guarantees a valid, residential UPRN. On success the +// property leaves the "Needs attention" tab (the page re-fetches). +export default function MatchAddress({ + property, + portfolioId, +}: { + property: UnmatchedProperty; + portfolioId: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [address, setAddress] = useState(property.address ?? ""); + const [postcode, setPostcode] = useState(property.postcode ?? ""); + // null = not searched yet; [] = searched, no residential matches. + const [residential, setResidential] = useState(null); + const [selected, setSelected] = useState(null); + const [source, setSource] = useState<"cache" | "live" | null>(null); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + + const assign = useAssignUprn(portfolioId, property.id); + + async function runSearch() { + setSearching(true); + setSearchError(null); + setResidential(null); + setSelected(null); + try { + const result = await searchAddresses(portfolioId, postcode); + // Residential only — we never assign a non-residential UPRN. + setResidential(result.candidates.filter((c) => c.selectable)); + setSource(result.source); + } catch (e) { + setSearchError(e instanceof Error ? e.message : "Search failed."); + } finally { + setSearching(false); + } + } + + function onAssign() { + if (!selected) return; + assign.mutate( + { + uprn: selected.uprn, + address: selected.address, + postcode: selected.postcode, + userInputtedAddress: address.trim() || undefined, + userInputtedPostcode: postcode.trim() || undefined, + }, + { + onSuccess: () => { + setOpen(false); + router.refresh(); + }, + }, + ); + } + + return ( + <> + + + + + + Match to a UPRN + + Confirm the postcode and pick the matching address. Only + residential addresses from Ordnance Survey are shown, so the UPRN + is always valid. + + + +
+
+ + setAddress(e.target.value)} + placeholder="e.g. 20 Brenchley Road" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
+
+
+ + setPostcode(e.target.value)} + placeholder="e.g. BR5 2TD" + onKeyDown={(e) => { + if (e.key === "Enter" && postcode.trim() && !searching) + runSearch(); + }} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
+ +
+ + {searchError &&

{searchError}

} + + {residential && ( +
+
+

+ Residential addresses +

+ {source === "cache" && ( + + from cache + + )} +
+
+ {residential.length === 0 ? ( +

+ No residential addresses found for that postcode. +

+ ) : ( +
    + {residential.map((c) => { + const { label } = describeCandidate(c); + const disabled = c.alreadyInPortfolio; + const isSelected = selected?.uprn === c.uprn; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ )} +
+ + + {assign.error && ( +

+ {assign.error.message} +

+ )} + + +
+
+
+ + ); +} diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx index a1f5d03d..024d4225 100644 --- a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx +++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx @@ -3,7 +3,7 @@ import { CheckCircleIcon, } from "@heroicons/react/24/outline"; import type { UnmatchedProperty } from "@/lib/properties/unmatched"; -import EditAddress from "./EditAddress"; +import MatchAddress from "./MatchAddress"; // The "Needs attention" tab: properties with no UPRN, separated out of the main // table until they're matched. Each row lets the user correct the address / @@ -81,7 +81,7 @@ export default function UnmatchedProperties({ {p.postcode ?? }

- +
))} diff --git a/src/lib/properties/client.ts b/src/lib/properties/client.ts index 44848424..36480528 100644 --- a/src/lib/properties/client.ts +++ b/src/lib/properties/client.ts @@ -7,14 +7,51 @@ async function parseError(res: Response, fallback: string): Promise { return new Error(body?.error ?? fallback); } -export interface UpdateAddressInput { +// One address candidate returned by the portfolio postcode search, plus the +// server-computed flag for candidates already assigned in this portfolio. +export interface SearchCandidate { + uprn: string; address: string; postcode: string; + classificationCode: string | null; + selectable: boolean; // residential (per OS classification) + propertyType: string | null; + builtForm: string | null; + alreadyInPortfolio: boolean; } -// Save an edited address / postcode onto a property. -export function useUpdateAddress(portfolioId: string, propertyId: string) { - return useMutation<{ ok: true }, Error, UpdateAddressInput>({ +export interface AddressSearchResult { + postcode: string; + source: "cache" | "live"; + candidates: SearchCandidate[]; +} + +// Run the postcode → address-candidate search. Reuses the add-properties +// endpoint, which read-through caches OS Places results per postcode in the +// postcode_search table (30-day TTL) — so repeat searches of the same postcode +// hit our DB, not the OS API. Throws with the server's { error } on failure. +export async function searchAddresses( + portfolioId: string, + postcode: string, +): Promise { + const res = await fetch( + `/api/portfolio/${portfolioId}/properties/search?postcode=${encodeURIComponent(postcode)}`, + ); + if (!res.ok) throw await parseError(res, "Address search failed."); + return res.json(); +} + +export interface AssignUprnInput { + uprn: string; + address: string; + postcode: string; + userInputtedAddress?: string; + userInputtedPostcode?: string; +} + +// Assign a chosen candidate's UPRN + address to a property. +export function useAssignUprn(portfolioId: string, propertyId: string) { + return useMutation<{ ok: true }, Error, AssignUprnInput>({ mutationFn: async (input) => { const res = await fetch( `/api/portfolio/${portfolioId}/properties/${propertyId}`, @@ -24,7 +61,7 @@ export function useUpdateAddress(portfolioId: string, propertyId: string) { body: JSON.stringify(input), }, ); - if (!res.ok) throw await parseError(res, "Failed to save address."); + if (!res.ok) throw await parseError(res, "Failed to assign UPRN."); return res.json(); }, }); From fe55a28622393da1adbf22ac7af475d9154a4253 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 11:37:10 +0000 Subject: [PATCH 16/37] fix(portfolio): truncate long match addresses; hide Needs attention tab when empty - MatchAddress: long OS addresses overflowed the candidate row (text column wasn't width-constrained). Give it min-w-0 flex-1 and truncate both lines (full address on hover) so the dialog layout holds. - PortfolioTabs: when nothing is unmatched, render just the properties table with no tab strip (the "Needs attention" tab only appears when count > 0). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/portfolio/[slug]/components/MatchAddress.tsx | 9 ++++++--- src/app/portfolio/[slug]/components/PortfolioTabs.tsx | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/[slug]/components/MatchAddress.tsx b/src/app/portfolio/[slug]/components/MatchAddress.tsx index b446bd00..549420bd 100644 --- a/src/app/portfolio/[slug]/components/MatchAddress.tsx +++ b/src/app/portfolio/[slug]/components/MatchAddress.tsx @@ -188,11 +188,14 @@ export default function MatchAddress({ isSelected ? "text-amber-600" : "text-gray-200" }`} /> - - + + {c.address} - + UPRN {c.uprn} · {label} {c.alreadyInPortfolio ? " · already in portfolio" diff --git a/src/app/portfolio/[slug]/components/PortfolioTabs.tsx b/src/app/portfolio/[slug]/components/PortfolioTabs.tsx index 59aba28d..887fee92 100644 --- a/src/app/portfolio/[slug]/components/PortfolioTabs.tsx +++ b/src/app/portfolio/[slug]/components/PortfolioTabs.tsx @@ -18,6 +18,9 @@ export default function PortfolioTabs({ }) { const [tab, setTab] = useState<"properties" | "needs-attention">("properties"); + // Nothing unmatched → no tab strip at all, just the properties table. + if (needsAttentionCount === 0) return <>{properties}; + return (
From 1ac9f0fdcf581f4db9f38717473d4709c68130e2 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 11:53:26 +0000 Subject: [PATCH 17/37] fix(portfolio): open the bulk-upload dialog from "Bulk excel import" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BulkUploadComingSoonModal (drag/drop upload, template download, header validation → creates the upload and routes into the map-columns onboarding step) existed but was never wired to anything. The "Bulk excel import" menu item routed to the uploads list instead. Point it at the dialog so the bulk upload onboarding starts from there. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../portfolio/[slug]/components/PropertyTable.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 6d452e42..119c5a32 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -17,6 +17,7 @@ import { } from "@heroicons/react/24/outline"; import { useRouter } from "next/navigation"; import { HomeIcon } from "@heroicons/react/24/outline"; +import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal"; import { sapToEpc } from "@/app/utils"; import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns"; import { PropertyWithRelations } from "@/app/db/schema/property"; @@ -307,6 +308,7 @@ export default function PropertyTable({ }) { const router = useRouter(); const [sidebarOpen, setSidebarOpen] = useState(false); + const [bulkImportOpen, setBulkImportOpen] = useState(false); const [committedAddress, setCommittedAddress] = useState(""); const [committedPostcode, setCommittedPostcode] = useState(""); @@ -655,9 +657,7 @@ export default function PropertyTable({ - router.push(`/portfolio/${portfolioId}/bulk-upload`) - } + onSelect={() => setBulkImportOpen(true)} > @@ -671,6 +671,12 @@ export default function PropertyTable({ + + setBulkImportOpen(false)} + portfolioId={portfolioId} + />
From 4073671127fc012ed8fd7ce4b34c2ced381e65f0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 13:23:15 +0000 Subject: [PATCH 18/37] feat(db): add user_portfolio_config and user_portfolio_folders tables Per-user portfolio workspace layer for the home-page redesign: starred portfolios (starred_at, non-null = starred, orderable) and personal folders (name + position, drag-reorderable). Composite FK (folder_id, user_id) -> (id, user_id) makes cross-user folder assignment impossible at the database level. Config rows are created lazily via upsert on (user_id, portfolio_id); portfolioUsers remains pure access control. Co-Authored-By: Claude Fable 5 --- .../db/migrations/0264_yummy_master_chief.sql | 27 + src/app/db/migrations/meta/0264_snapshot.json | 12020 ++++++++++++++++ src/app/db/migrations/meta/_journal.json | 7 + src/app/db/schema/user_portfolio_config.ts | 117 + 4 files changed, 12171 insertions(+) create mode 100644 src/app/db/migrations/0264_yummy_master_chief.sql create mode 100644 src/app/db/migrations/meta/0264_snapshot.json create mode 100644 src/app/db/schema/user_portfolio_config.ts diff --git a/src/app/db/migrations/0264_yummy_master_chief.sql b/src/app/db/migrations/0264_yummy_master_chief.sql new file mode 100644 index 00000000..022d479c --- /dev/null +++ b/src/app/db/migrations/0264_yummy_master_chief.sql @@ -0,0 +1,27 @@ +CREATE TABLE "user_portfolio_config" ( + "id" bigserial PRIMARY KEY NOT NULL, + "user_id" bigint NOT NULL, + "portfolio_id" bigint NOT NULL, + "starred_at" timestamp (6) with time zone, + "folder_id" bigint, + "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_portfolio_config_user_portfolio_unique" UNIQUE("user_id","portfolio_id") +); +--> statement-breakpoint +CREATE TABLE "user_portfolio_folders" ( + "id" bigserial PRIMARY KEY NOT NULL, + "user_id" bigint NOT NULL, + "name" text NOT NULL, + "position" integer NOT NULL, + "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_portfolio_folders_id_user_unique" UNIQUE("id","user_id") +); +--> statement-breakpoint +ALTER TABLE "user_portfolio_config" ADD CONSTRAINT "user_portfolio_config_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_portfolio_config" ADD CONSTRAINT "user_portfolio_config_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_portfolio_config" ADD CONSTRAINT "user_portfolio_config_folder_owner_fk" FOREIGN KEY ("folder_id","user_id") REFERENCES "public"."user_portfolio_folders"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_portfolio_folders" ADD CONSTRAINT "user_portfolio_folders_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_user_portfolio_config_folder" ON "user_portfolio_config" USING btree ("folder_id");--> statement-breakpoint +CREATE INDEX "idx_user_portfolio_folders_user_position" ON "user_portfolio_folders" USING btree ("user_id","position"); \ No newline at end of file diff --git a/src/app/db/migrations/meta/0264_snapshot.json b/src/app/db/migrations/meta/0264_snapshot.json new file mode 100644 index 00000000..4b945f78 --- /dev/null +++ b/src/app/db/migrations/meta/0264_snapshot.json @@ -0,0 +1,12020 @@ +{ + "id": "e7d5041b-5e3d-4f12-af83-3c67c451e234", + "prevId": "c790d01a-8e2f-491e-b279-612cba18bd57", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.postcode_search": { + "name": "postcode_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_data": { + "name": "result_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_updated_at": { + "name": "last_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "postcode_search_postcode_unique": { + "name": "postcode_search_postcode_unique", + "nullsNotDistinct": false, + "columns": [ + "postcode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deal_measure_approval_events": { + "name": "deal_measure_approval_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_by": { + "name": "acted_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deal_measure_events_deal_id": { + "name": "idx_deal_measure_events_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deal_measure_events_acted_at": { + "name": "idx_deal_measure_events_acted_at", + "columns": [ + { + "expression": "acted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deal_measure_approval_events_acted_by_user_id_fk": { + "name": "deal_measure_approval_events_acted_by_user_id_fk", + "tableFrom": "deal_measure_approval_events", + "tableTo": "user", + "columnsFrom": [ + "acted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deal_measure_approvals": { + "name": "deal_measure_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_approved": { + "name": "is_approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "approved_by": { + "name": "approved_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deal_measure_approvals_deal_id": { + "name": "idx_deal_measure_approvals_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deal_measure_approvals_approved_by_user_id_fk": { + "name": "deal_measure_approvals_approved_by_user_id_fk", + "tableFrom": "deal_measure_approvals", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_deal_measure": { + "name": "uq_deal_measure", + "nullsNotDistinct": false, + "columns": [ + "hubspot_deal_id", + "measure_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bulk_address_uploads": { + "name": "bulk_address_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_bucket": { + "name": "s3_bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready_for_processing'" + }, + "source_headers": { + "name": "source_headers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "column_mapping": { + "name": "column_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "multi_entry_summary": { + "name": "multi_entry_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "multi_entry_ordering": { + "name": "multi_entry_ordering", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verify_ack": { + "name": "verify_ack", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "combined_output_s3_uri": { + "name": "combined_output_s3_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.aspect_condition": { + "name": "aspect_condition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "element_id": { + "name": "element_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "aspect_type": { + "name": "aspect_type", + "type": "aspect_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "aspect_instance": { + "name": "aspect_instance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "install_date": { + "name": "install_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "renewal_year": { + "name": "renewal_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "comments": { + "name": "comments", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "aspect_condition_element_id_element_id_fk": { + "name": "aspect_condition_element_id_element_id_fk", + "tableFrom": "aspect_condition", + "tableTo": "element", + "columnsFrom": [ + "element_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.element": { + "name": "element", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "survey_id": { + "name": "survey_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "element_type": { + "name": "element_type", + "type": "element_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "element_instance": { + "name": "element_instance", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "element_survey_id_property_condition_survey_id_fk": { + "name": "element_survey_id_property_condition_survey_id_fk", + "tableFrom": "element", + "tableTo": "property_condition_survey", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_condition_survey": { + "name": "property_condition_survey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hubspot_company_data": { + "name": "hubspot_company_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hubspot_deal_data": { + "name": "hubspot_deal_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deal_id": { + "name": "deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dealname": { + "name": "dealname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dealstage": { + "name": "dealstage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_code": { + "name": "project_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landlord_property_id": { + "name": "landlord_property_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_id": { + "name": "listing_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uprn": { + "name": "uprn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome_notes": { + "name": "outcome_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "major_condition_issue_description": { + "name": "major_condition_issue_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "major_condition_issue_photos": { + "name": "major_condition_issue_photos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "major_condition_issue_evidence_s3_url": { + "name": "major_condition_issue_evidence_s3_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coordination_status": { + "name": "coordination_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "design_status": { + "name": "design_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "booking_status": { + "name": "booking_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pashub_link": { + "name": "pashub_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sharepoint_link": { + "name": "sharepoint_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dampmould_growth": { + "name": "dampmould_growth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pre_sap": { + "name": "pre_sap", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coordinator": { + "name": "coordinator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mtp_completion_date": { + "name": "mtp_completion_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "mtp_re_model_completion_date": { + "name": "mtp_re_model_completion_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "ioe_v3_completion_date": { + "name": "ioe_v3_completion_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "proposed_measures": { + "name": "proposed_measures", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_package": { + "name": "approved_package", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "designer": { + "name": "designer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "design_type": { + "name": "design_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "design_completion_date": { + "name": "design_completion_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "actual_measures_installed": { + "name": "actual_measures_installed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installer": { + "name": "installer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installer_handover": { + "name": "installer_handover", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lodgement_status": { + "name": "lodgement_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "measures_lodgement_date": { + "name": "measures_lodgement_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "lodgement_date": { + "name": "lodgement_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "expected_commencement_date": { + "name": "expected_commencement_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "coordination_comments": { + "name": "coordination_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surveyor": { + "name": "surveyor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "damp_mould_and_repairs_comments": { + "name": "damp_mould_and_repairs_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch": { + "name": "batch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch_description": { + "name": "batch_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_reference": { + "name": "block_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonfunded_measures": { + "name": "nonfunded_measures", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_prn": { + "name": "epc_prn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "potential_post_sap_score_dropdown": { + "name": "potential_post_sap_score_dropdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ei_score": { + "name": "ei_score", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ei_score__potential_": { + "name": "ei_score__potential_", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_sap_score": { + "name": "epc_sap_score", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_sap_score__potential_": { + "name": "epc_sap_score__potential_", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmed_survey_date": { + "name": "confirmed_survey_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmed_survey_time": { + "name": "confirmed_survey_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surveyed_date": { + "name": "surveyed_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "survey_type": { + "name": "survey_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "measures_for_pibi_ordered": { + "name": "measures_for_pibi_ordered", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pibi_order_date": { + "name": "pibi_order_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "pibi_completed_date": { + "name": "pibi_completed_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "property_halted_date": { + "name": "property_halted_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "property_halted_reason": { + "name": "property_halted_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technical_approved_measures_for_install": { + "name": "technical_approved_measures_for_install", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_to_installer_for_pricing": { + "name": "sent_to_installer_for_pricing", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "domna_survey_required": { + "name": "domna_survey_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "domna_survey_type": { + "name": "domna_survey_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domna_survey_date": { + "name": "domna_survey_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "date_booking_made": { + "name": "date_booking_made", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_contact_date": { + "name": "last_contact_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_outbound_call": { + "name": "last_outbound_call", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_outbound_email": { + "name": "last_outbound_email", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_submission_date": { + "name": "last_submission_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "number_of_attempts": { + "name": "number_of_attempts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_booking_reference": { + "name": "client_booking_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hubspot_projects_data": { + "name": "hubspot_projects_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hubspot_projects_data_project_id_unique": { + "name": "hubspot_projects_data_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hubspot_users": { + "name": "hubspot_users", + "schema": "", + "columns": { + "hubspot_owner_id": { + "name": "hubspot_owner_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_status_tracker": { + "name": "property_status_tracker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "property_status_tracker_property_id_property_id_fk": { + "name": "property_status_tracker_property_id_property_id_fk", + "tableFrom": "property_status_tracker", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_status_tracker_portfolio_id_portfolio_id_fk": { + "name": "property_status_tracker_portfolio_id_portfolio_id_fk", + "tableFrom": "property_status_tracker", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.energy_assessments": { + "name": "energy_assessments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uprn_source": { + "name": "uprn_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_reference_number": { + "name": "building_reference_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_energy_efficiency": { + "name": "current_energy_efficiency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_energy_rating": { + "name": "current_energy_rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address1": { + "name": "address1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address2": { + "name": "address2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address3": { + "name": "address3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posttown": { + "name": "posttown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency": { + "name": "constituency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency_label": { + "name": "constituency_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "low_energy_fixed_light_count": { + "name": "low_energy_fixed_light_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "construction_age_band": { + "name": "construction_age_band", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheat_energy_eff": { + "name": "mainheat_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_env_eff": { + "name": "windows_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_energy_eff": { + "name": "lighting_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_impact_potential": { + "name": "environment_impact_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatcont_description": { + "name": "mainheatcont_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheating_energy_eff": { + "name": "sheating_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_authority": { + "name": "local_authority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_authority_label": { + "name": "local_authority_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fixed_lighting_outlets_count": { + "name": "fixed_lighting_outlets_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_tariff": { + "name": "energy_tariff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mechanical_ventilation": { + "name": "mechanical_ventilation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "solar_water_heating_flag": { + "name": "solar_water_heating_flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emissions_potential": { + "name": "co2_emissions_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_heated_rooms": { + "name": "number_heated_rooms", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_description": { + "name": "floor_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_consumption_potential": { + "name": "energy_consumption_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "built_form": { + "name": "built_form", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_open_fireplaces": { + "name": "number_open_fireplaces", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_description": { + "name": "windows_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "glazed_area": { + "name": "glazed_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inspection_date": { + "name": "inspection_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "mains_gas_flag": { + "name": "mains_gas_flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emiss_curr_per_floor_area": { + "name": "co2_emiss_curr_per_floor_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heat_loss_corridor": { + "name": "heat_loss_corridor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unheated_corridor_length": { + "name": "unheated_corridor_length", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flat_storey_count": { + "name": "flat_storey_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roof_energy_eff": { + "name": "roof_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_floor_area": { + "name": "total_floor_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_impact_current": { + "name": "environment_impact_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roof_description": { + "name": "roof_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_energy_eff": { + "name": "floor_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_habitable_rooms": { + "name": "number_habitable_rooms", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_env_eff": { + "name": "hot_water_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatc_energy_eff": { + "name": "mainheatc_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "main_fuel": { + "name": "main_fuel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_env_eff": { + "name": "lighting_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_energy_eff": { + "name": "windows_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_env_eff": { + "name": "floor_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheating_env_eff": { + "name": "sheating_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_description": { + "name": "lighting_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roof_env_eff": { + "name": "roof_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_energy_eff": { + "name": "walls_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_supply": { + "name": "photo_supply", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_cost_potential": { + "name": "lighting_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheat_env_eff": { + "name": "mainheat_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "multi_glaze_proportion": { + "name": "multi_glaze_proportion", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "main_heating_controls": { + "name": "main_heating_controls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flat_top_storey": { + "name": "flat_top_storey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secondheat_description": { + "name": "secondheat_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_env_eff": { + "name": "walls_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension_count": { + "name": "extension_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatc_env_eff": { + "name": "mainheatc_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lmk_key": { + "name": "lmk_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wind_turbine_count": { + "name": "wind_turbine_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenure": { + "name": "tenure", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_level": { + "name": "floor_level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "potential_energy_efficiency": { + "name": "potential_energy_efficiency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "potential_energy_rating": { + "name": "potential_energy_rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_energy_eff": { + "name": "hot_water_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "low_energy_lighting": { + "name": "low_energy_lighting", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_description": { + "name": "walls_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hotwater_description": { + "name": "hotwater_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emissions_current": { + "name": "co2_emissions_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heating_cost_current": { + "name": "heating_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heating_cost_potential": { + "name": "heating_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_cost_current": { + "name": "hot_water_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_cost_potential": { + "name": "hot_water_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_cost_current": { + "name": "lighting_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_consumption_current": { + "name": "energy_consumption_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lodgement_date": { + "name": "lodgement_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "lodgement_datetime": { + "name": "lodgement_datetime", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true + }, + "mainheat_description": { + "name": "mainheat_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_height": { + "name": "floor_height", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "glazed_type": { + "name": "glazed_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_location": { + "name": "file_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surveyor_name": { + "name": "surveyor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surveyor_company": { + "name": "surveyor_company", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "space_heating_kwh": { + "name": "space_heating_kwh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "water_heating_kwh": { + "name": "water_heating_kwh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_of_doors": { + "name": "number_of_doors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number_of_insulated_doors": { + "name": "number_of_insulated_doors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number_of_floors": { + "name": "number_of_floors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "insulation_wall_area": { + "name": "insulation_wall_area", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "heat_loss_perimeter": { + "name": "heat_loss_perimeter", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "party_wall_length": { + "name": "party_wall_length", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "perimeter": { + "name": "perimeter", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "rooms_with_bath_and_or_shower": { + "name": "rooms_with_bath_and_or_shower", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rooms_with_mixer_shower_no_bath": { + "name": "rooms_with_mixer_shower_no_bath", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "room_with_bath_and_mixer_shower": { + "name": "room_with_bath_and_mixer_shower", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent_draftproofed": { + "name": "percent_draftproofed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_hot_water_cylinder": { + "name": "has_hot_water_cylinder", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cylinder_insulation_type": { + "name": "cylinder_insulation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cylinder_insulation_thickness": { + "name": "cylinder_insulation_thickness", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cylinder_thermostat": { + "name": "cylinder_thermostat", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "main_dwelling_ground_floor_area": { + "name": "main_dwelling_ground_floor_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_windows": { + "name": "number_of_windows", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "windows_area": { + "name": "windows_area", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.energy_assessment_documents": { + "name": "energy_assessment_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "energy_assessment_id": { + "name": "energy_assessment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "document_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "document_location": { + "name": "document_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scenario_id": { + "name": "scenario_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk": { + "name": "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk", + "tableFrom": "energy_assessment_documents", + "tableTo": "energy_assessments", + "columnsFrom": [ + "energy_assessment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk": { + "name": "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk", + "tableFrom": "energy_assessment_documents", + "tableTo": "energy_assessment_scenarios", + "columnsFrom": [ + "scenario_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.energy_assessment_scenarios": { + "name": "energy_assessment_scenarios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "scenario_name": { + "name": "scenario_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_assessment_id": { + "name": "energy_assessment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk": { + "name": "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk", + "tableFrom": "energy_assessment_scenarios", + "tableTo": "energy_assessments", + "columnsFrom": [ + "energy_assessment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_store": { + "name": "epc_store", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "epc_api_created_at": { + "name": "epc_api_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "epc_api": { + "name": "epc_api", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "epc_page_created_at": { + "name": "epc_page_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "epc_page": { + "name": "epc_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_page_rrn": { + "name": "epc_page_rrn", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_epc_store_uprn": { + "name": "uq_epc_store_uprn", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files_from_surveyor": { + "name": "files_from_surveyor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "s3_json_url": { + "name": "s3_json_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "files_from_surveyor_portfolio_id_portfolio_id_fk": { + "name": "files_from_surveyor_portfolio_id_portfolio_id_fk", + "tableFrom": "files_from_surveyor", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_from_surveyor_property_id_property_id_fk": { + "name": "files_from_surveyor_property_id_property_id_fk", + "tableFrom": "files_from_surveyor", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_package": { + "name": "funding_package", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scheme": { + "name": "scheme", + "type": "scheme", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "project_funding": { + "name": "project_funding", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_uplift": { + "name": "total_uplift", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "full_project_score": { + "name": "full_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "partial_project_score": { + "name": "partial_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "uplift_project_score": { + "name": "uplift_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "funding_package_plan_id_plan_id_fk": { + "name": "funding_package_plan_id_plan_id_fk", + "tableFrom": "funding_package", + "tableTo": "plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_package_measures": { + "name": "funding_package_measures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "funding_package_id": { + "name": "funding_package_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "measure": { + "name": "measure", + "type": "type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "innovation_uplift": { + "name": "innovation_uplift", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "partial_project_score": { + "name": "partial_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "uplift_project_score": { + "name": "uplift_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "funding_package_measures_funding_package_id_funding_package_id_fk": { + "name": "funding_package_measures_funding_package_id_funding_package_id_fk", + "tableFrom": "funding_package_measures", + "tableTo": "funding_package", + "columnsFrom": [ + "funding_package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "funding_package_measures_material_id_material_id_fk": { + "name": "funding_package_measures_material_id_material_id_fk", + "tableFrom": "funding_package_measures", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inspections": { + "name": "inspections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "archetype": { + "name": "archetype", + "type": "inspection_archetype", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "archetype_2": { + "name": "archetype_2", + "type": "inspection_archetype_2", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "wall_construction": { + "name": "wall_construction", + "type": "inspections_wall_construction", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "insulation": { + "name": "insulation", + "type": "inspections_wall_insulation", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "insulation_material": { + "name": "insulation_material", + "type": "inspections_insulation_material", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "borescoped": { + "name": "borescoped", + "type": "inspection_borescoped", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "roof_orientation": { + "name": "roof_orientation", + "type": "inspections_roof_orientation", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tile_hung": { + "name": "tile_hung", + "type": "inspections_tile_hung", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "rendered": { + "name": "rendered", + "type": "inspections_rendered", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cladding": { + "name": "cladding", + "type": "inspections_cladding", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "access_issues": { + "name": "access_issues", + "type": "inspections_access_issues", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "surveyor_name": { + "name": "surveyor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inspections_property_id_property_id_fk": { + "name": "inspections_property_id_property_id_fk", + "tableFrom": "inspections", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_built_form_type_overrides": { + "name": "landlord_built_form_type_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "built_form_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_built_form_type_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_built_form_type_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_built_form_type_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_built_form_type_overrides_portfolio_description_unique": { + "name": "landlord_built_form_type_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_construction_age_band_overrides": { + "name": "landlord_construction_age_band_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "construction_age_band", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_construction_age_band_overrides_portfolio_fk": { + "name": "landlord_construction_age_band_overrides_portfolio_fk", + "tableFrom": "landlord_construction_age_band_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_construction_age_band_portfolio_description_unique": { + "name": "landlord_construction_age_band_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_glazing_overrides": { + "name": "landlord_glazing_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "glazing", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_glazing_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_glazing_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_glazing_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_glazing_overrides_portfolio_description_unique": { + "name": "landlord_glazing_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_main_fuel_overrides": { + "name": "landlord_main_fuel_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "main_fuel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_main_fuel_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_main_fuel_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_main_fuel_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_main_fuel_overrides_portfolio_description_unique": { + "name": "landlord_main_fuel_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_main_heating_system_overrides": { + "name": "landlord_main_heating_system_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "main_heating_system", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_main_heating_system_overrides_portfolio_fk": { + "name": "landlord_main_heating_system_overrides_portfolio_fk", + "tableFrom": "landlord_main_heating_system_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_main_heating_system_portfolio_description_unique": { + "name": "landlord_main_heating_system_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_property_type_overrides": { + "name": "landlord_property_type_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "property_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_property_type_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_property_type_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_property_type_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_property_type_overrides_portfolio_description_unique": { + "name": "landlord_property_type_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_roof_type_overrides": { + "name": "landlord_roof_type_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "roof_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_roof_type_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_roof_type_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_roof_type_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_roof_type_overrides_portfolio_description_unique": { + "name": "landlord_roof_type_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_wall_type_overrides": { + "name": "landlord_wall_type_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "wall_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_wall_type_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_wall_type_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_wall_type_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_wall_type_overrides_portfolio_description_unique": { + "name": "landlord_wall_type_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_water_heating_overrides": { + "name": "landlord_water_heating_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "water_heating", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_water_heating_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_water_heating_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_water_heating_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_water_heating_overrides_portfolio_description_unique": { + "name": "landlord_water_heating_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_door": { + "name": "magic_plan_door", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_room_id": { + "name": "magic_plan_room_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "width_mm": { + "name": "width_mm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "height_mm": { + "name": "height_mm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_door_magic_plan_room_id_magic_plan_room_id_fk": { + "name": "magic_plan_door_magic_plan_room_id_magic_plan_room_id_fk", + "tableFrom": "magic_plan_door", + "tableTo": "magic_plan_room", + "columnsFrom": [ + "magic_plan_room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_door_ventilation": { + "name": "magic_plan_door_ventilation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_door_id": { + "name": "magic_plan_door_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "undercut_mm": { + "name": "undercut_mm", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_door_ventilation_magic_plan_door_id_magic_plan_door_id_fk": { + "name": "magic_plan_door_ventilation_magic_plan_door_id_magic_plan_door_id_fk", + "tableFrom": "magic_plan_door_ventilation", + "tableTo": "magic_plan_door", + "columnsFrom": [ + "magic_plan_door_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_plan_door_ventilation_magic_plan_door_id_unique": { + "name": "magic_plan_door_ventilation_magic_plan_door_id_unique", + "nullsNotDistinct": false, + "columns": [ + "magic_plan_door_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_floor": { + "name": "magic_plan_floor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_plan_id": { + "name": "magic_plan_plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_floor_magic_plan_plan_id_magic_plan_plan_id_fk": { + "name": "magic_plan_floor_magic_plan_plan_id_magic_plan_plan_id_fk", + "tableFrom": "magic_plan_floor", + "tableTo": "magic_plan_plan", + "columnsFrom": [ + "magic_plan_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_plan": { + "name": "magic_plan_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "magic_plan_uid": { + "name": "magic_plan_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_file_id": { + "name": "uploaded_file_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_plan_uploaded_file_id_uploaded_files_id_fk": { + "name": "magic_plan_plan_uploaded_file_id_uploaded_files_id_fk", + "tableFrom": "magic_plan_plan", + "tableTo": "uploaded_files", + "columnsFrom": [ + "uploaded_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_plan_plan_magic_plan_uid_unique": { + "name": "magic_plan_plan_magic_plan_uid_unique", + "nullsNotDistinct": false, + "columns": [ + "magic_plan_uid" + ] + }, + "magic_plan_plan_uploaded_file_id_unique": { + "name": "magic_plan_plan_uploaded_file_id_unique", + "nullsNotDistinct": false, + "columns": [ + "uploaded_file_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_room": { + "name": "magic_plan_room", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_floor_id": { + "name": "magic_plan_floor_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width_m": { + "name": "width_m", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "length_m": { + "name": "length_m", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "area_m2": { + "name": "area_m2", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_room_magic_plan_floor_id_magic_plan_floor_id_fk": { + "name": "magic_plan_room_magic_plan_floor_id_magic_plan_floor_id_fk", + "tableFrom": "magic_plan_room", + "tableTo": "magic_plan_floor", + "columnsFrom": [ + "magic_plan_floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_window": { + "name": "magic_plan_window", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_room_id": { + "name": "magic_plan_room_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "width_m": { + "name": "width_m", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "height_m": { + "name": "height_m", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "area_m2": { + "name": "area_m2", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_window_magic_plan_room_id_magic_plan_room_id_fk": { + "name": "magic_plan_window_magic_plan_room_id_magic_plan_room_id_fk", + "tableFrom": "magic_plan_window", + "tableTo": "magic_plan_room", + "columnsFrom": [ + "magic_plan_room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_window_ventilation": { + "name": "magic_plan_window_ventilation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_window_id": { + "name": "magic_plan_window_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "opening_type": { + "name": "opening_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "num_openings": { + "name": "num_openings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pct_openable": { + "name": "pct_openable", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trickle_vent_area_mm2": { + "name": "trickle_vent_area_mm2", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "num_trickle_vents": { + "name": "num_trickle_vents", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_window_ventilation_magic_plan_window_id_magic_plan_window_id_fk": { + "name": "magic_plan_window_ventilation_magic_plan_window_id_magic_plan_window_id_fk", + "tableFrom": "magic_plan_window_ventilation", + "tableTo": "magic_plan_window", + "columnsFrom": [ + "magic_plan_window_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_plan_window_ventilation_magic_plan_window_id_unique": { + "name": "magic_plan_window_ventilation_magic_plan_window_id_unique", + "nullsNotDistinct": false, + "columns": [ + "magic_plan_window_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material": { + "name": "material", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "depth": { + "name": "depth", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "depth_unit": { + "name": "depth_unit", + "type": "depth_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cost_unit": { + "name": "cost_unit", + "type": "cost_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "r_value_per_mm": { + "name": "r_value_per_mm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "r_value_unit": { + "name": "r_value_unit", + "type": "r_value_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "thermal_conductivity": { + "name": "thermal_conductivity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "thermal_conductivity_unit": { + "name": "thermal_conductivity_unit", + "type": "thermal_conductivity_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "prime_material_cost": { + "name": "prime_material_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "material_cost": { + "name": "material_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_cost": { + "name": "labour_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_hours_per_unit": { + "name": "labour_hours_per_unit", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "plant_cost": { + "name": "plant_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_installer_quote": { + "name": "is_installer_quote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "innovation_rate": { + "name": "innovation_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "size": { + "name": "size", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "size_unit": { + "name": "size_unit", + "type": "size_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "includes_scaffolding": { + "name": "includes_scaffolding", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "includes_battery": { + "name": "includes_battery", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "battery_size": { + "name": "battery_size", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_material_active": { + "name": "idx_material_active", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"material\".\"is_active\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hubspot_company_id": { + "name": "hubspot_company_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pibi_requests": { + "name": "pibi_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordered_at": { + "name": "ordered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pushed_at": { + "name": "pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_pibi_requests_deal_id": { + "name": "idx_pibi_requests_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pibi_requests_portfolio_id": { + "name": "idx_pibi_requests_portfolio_id", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pibi_requests_portfolio_id_portfolio_id_fk": { + "name": "pibi_requests_portfolio_id_portfolio_id_fk", + "tableFrom": "pibi_requests", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pibi_requests_created_by_user_id_user_id_fk": { + "name": "pibi_requests_created_by_user_id_user_id_fk", + "tableFrom": "pibi_requests", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_organisation": { + "name": "portfolio_organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolio_organisation_portfolio_id_portfolio_id_fk": { + "name": "portfolio_organisation_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolio_organisation", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolio_organisation_organisation_id_organisation_id_fk": { + "name": "portfolio_organisation_organisation_id_organisation_id_fk", + "tableFrom": "portfolio_organisation", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "portfolio_organisation_portfolio_id_organisation_id_unique": { + "name": "portfolio_organisation_portfolio_id_organisation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "organisation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio": { + "name": "portfolio", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "budget": { + "name": "budget", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "goal", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_properties": { + "name": "number_of_properties", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rental_yield_increase": { + "name": "rental_yield_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "epc_breakdown_pre_retrofit": { + "name": "epc_breakdown_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_post_retrofit": { + "name": "epc_breakdown_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "n_units_to_retrofit": { + "name": "n_units_to_retrofit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_pre_retrofit": { + "name": "co2_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_post_retrofit": { + "name": "co2_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_pre_retrofit": { + "name": "energy_bill_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_post_retrofit": { + "name": "energy_bill_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_pre_retrofit": { + "name": "energy_consumption_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_post_retrofit": { + "name": "energy_consumption_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_improvement_per_unit": { + "name": "valuation_improvement_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_unit": { + "name": "cost_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_co2_saved": { + "name": "cost_per_co2_saved", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_sap_point": { + "name": "cost_per_sap_point", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_return_on_investment": { + "name": "valuation_return_on_investment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_capabilities": { + "name": "portfolio_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "portfolio_capability", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolio_capabilities_user_id_user_id_fk": { + "name": "portfolio_capabilities_user_id_user_id_fk", + "tableFrom": "portfolio_capabilities", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "portfolio_capabilities_portfolio_id_portfolio_id_fk": { + "name": "portfolio_capabilities_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolio_capabilities", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "portfolio_capabilities_user_id_portfolio_id_capability_unique": { + "name": "portfolio_capabilities_user_id_portfolio_id_capability_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "portfolio_id", + "capability" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolioInvitations": { + "name": "portfolioInvitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolioInvitations_portfolio_id_portfolio_id_fk": { + "name": "portfolioInvitations_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolioInvitations", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolioInvitations_invited_by_user_id_user_id_fk": { + "name": "portfolioInvitations_invited_by_user_id_user_id_fk", + "tableFrom": "portfolioInvitations", + "tableTo": "user", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "portfolio_invitations_portfolio_email_unique": { + "name": "portfolio_invitations_portfolio_email_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolioUsers": { + "name": "portfolioUsers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolioUsers_user_id_user_id_fk": { + "name": "portfolioUsers_user_id_user_id_fk", + "tableFrom": "portfolioUsers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "portfolioUsers_portfolio_id_portfolio_id_fk": { + "name": "portfolioUsers_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolioUsers", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_overrides": { + "name": "property_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "building_part": { + "name": "building_part", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "override_component": { + "name": "override_component", + "type": "override_component", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "override_value": { + "name": "override_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_spreadsheet_description": { + "name": "original_spreadsheet_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "property_overrides_property_id_property_id_fk": { + "name": "property_overrides_property_id_property_id_fk", + "tableFrom": "property_overrides", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_overrides_portfolio_id_portfolio_id_fk": { + "name": "property_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "property_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "property_overrides_property_component_part_unique": { + "name": "property_overrides_property_component_part_unique", + "nullsNotDistinct": false, + "columns": [ + "property_id", + "override_component", + "building_part" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_building_part": { + "name": "epc_building_part", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "construction_age_band": { + "name": "construction_age_band", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wall_construction": { + "name": "wall_construction", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "wall_insulation_type": { + "name": "wall_insulation_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "wall_thickness_measured": { + "name": "wall_thickness_measured", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "party_wall_construction": { + "name": "party_wall_construction", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "building_part_number": { + "name": "building_part_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_dry_lined": { + "name": "wall_dry_lined", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "wall_thickness_mm": { + "name": "wall_thickness_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_insulation_thickness": { + "name": "wall_insulation_thickness", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "wall_insulation_thermal_conductivity": { + "name": "wall_insulation_thermal_conductivity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "floor_heat_loss": { + "name": "floor_heat_loss", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floor_insulation_thickness": { + "name": "floor_insulation_thickness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flat_roof_insulation_thickness": { + "name": "flat_roof_insulation_thickness", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "floor_type": { + "name": "floor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_construction_type": { + "name": "floor_construction_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_insulation_type_str": { + "name": "floor_insulation_type_str", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_u_value_known": { + "name": "floor_u_value_known", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "roof_construction": { + "name": "roof_construction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "roof_insulation_location": { + "name": "roof_insulation_location", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "roof_insulation_thickness": { + "name": "roof_insulation_thickness", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "roof_construction_type": { + "name": "roof_construction_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "curtain_wall_age": { + "name": "curtain_wall_age", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "room_in_roof_floor_area": { + "name": "room_in_roof_floor_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "room_in_roof_construction_age_band": { + "name": "room_in_roof_construction_age_band", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_area": { + "name": "alt_wall_1_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_dry_lined": { + "name": "alt_wall_1_dry_lined", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_construction": { + "name": "alt_wall_1_construction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_insulation_type": { + "name": "alt_wall_1_insulation_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_thickness_measured": { + "name": "alt_wall_1_thickness_measured", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_insulation_thickness": { + "name": "alt_wall_1_insulation_thickness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_is_sheltered": { + "name": "alt_wall_1_is_sheltered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_area": { + "name": "alt_wall_2_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_dry_lined": { + "name": "alt_wall_2_dry_lined", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_construction": { + "name": "alt_wall_2_construction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_insulation_type": { + "name": "alt_wall_2_insulation_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_thickness_measured": { + "name": "alt_wall_2_thickness_measured", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_insulation_thickness": { + "name": "alt_wall_2_insulation_thickness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_is_sheltered": { + "name": "alt_wall_2_is_sheltered", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ix_epc_building_part_epc_property": { + "name": "ix_epc_building_part_epc_property", + "columns": [ + { + "expression": "epc_property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "epc_building_part_epc_property_id_epc_property_id_fk": { + "name": "epc_building_part_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_building_part", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_energy_element": { + "name": "epc_energy_element", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "element_type": { + "name": "element_type", + "type": "energy_element_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_efficiency_rating": { + "name": "energy_efficiency_rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "environmental_efficiency_rating": { + "name": "environmental_efficiency_rating", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ix_epc_energy_element_epc_property": { + "name": "ix_epc_energy_element_epc_property", + "columns": [ + { + "expression": "epc_property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "epc_energy_element_epc_property_id_epc_property_id_fk": { + "name": "epc_energy_element_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_energy_element", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_flat_details": { + "name": "epc_flat_details", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "top_storey": { + "name": "top_storey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flat_location": { + "name": "flat_location", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "heat_loss_corridor": { + "name": "heat_loss_corridor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storey_count": { + "name": "storey_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unheated_corridor_length_m": { + "name": "unheated_corridor_length_m", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_flat_details_epc_property_id_epc_property_id_fk": { + "name": "epc_flat_details_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_flat_details", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "epc_flat_details_epc_property_id_unique": { + "name": "epc_flat_details_epc_property_id_unique", + "nullsNotDistinct": false, + "columns": [ + "epc_property_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_floor_dimension": { + "name": "epc_floor_dimension", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_building_part_id": { + "name": "epc_building_part_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "room_height_m": { + "name": "room_height_m", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "total_floor_area_m2": { + "name": "total_floor_area_m2", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "party_wall_length_m": { + "name": "party_wall_length_m", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "heat_loss_perimeter_m": { + "name": "heat_loss_perimeter_m", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "floor_insulation": { + "name": "floor_insulation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floor_construction": { + "name": "floor_construction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_exposed_floor": { + "name": "is_exposed_floor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_above_partially_heated_space": { + "name": "is_above_partially_heated_space", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "ix_epc_floor_dimension_building_part": { + "name": "ix_epc_floor_dimension_building_part", + "columns": [ + { + "expression": "epc_building_part_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "epc_floor_dimension_epc_building_part_id_epc_building_part_id_fk": { + "name": "epc_floor_dimension_epc_building_part_id_epc_building_part_id_fk", + "tableFrom": "epc_floor_dimension", + "tableTo": "epc_building_part", + "columnsFrom": [ + "epc_building_part_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_main_heating_detail": { + "name": "epc_main_heating_detail", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "has_fghrs": { + "name": "has_fghrs", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "main_fuel_type": { + "name": "main_fuel_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "heat_emitter_type": { + "name": "heat_emitter_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "emitter_temperature": { + "name": "emitter_temperature", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "main_heating_control": { + "name": "main_heating_control", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fan_flue_present": { + "name": "fan_flue_present", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boiler_flue_type": { + "name": "boiler_flue_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "boiler_ignition_type": { + "name": "boiler_ignition_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "central_heating_pump_age": { + "name": "central_heating_pump_age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "central_heating_pump_age_str": { + "name": "central_heating_pump_age_str", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "main_heating_index_number": { + "name": "main_heating_index_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sap_main_heating_code": { + "name": "sap_main_heating_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "main_heating_number": { + "name": "main_heating_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "main_heating_category": { + "name": "main_heating_category", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "main_heating_fraction": { + "name": "main_heating_fraction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "main_heating_data_source": { + "name": "main_heating_data_source", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "condensing": { + "name": "condensing", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "weather_compensator": { + "name": "weather_compensator", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "community_heating_boiler_fuel_type": { + "name": "community_heating_boiler_fuel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "community_heating_chp_fraction": { + "name": "community_heating_chp_fraction", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_main_heating_detail_epc_property_id_epc_property_id_fk": { + "name": "epc_main_heating_detail_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_main_heating_detail", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_photovoltaic_array": { + "name": "epc_photovoltaic_array", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "array_index": { + "name": "array_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "peak_power": { + "name": "peak_power", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "pitch": { + "name": "pitch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "overshading": { + "name": "overshading", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "orientation": { + "name": "orientation", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_photovoltaic_array_epc_property_id_epc_property_id_fk": { + "name": "epc_photovoltaic_array_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_photovoltaic_array", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_property": { + "name": "epc_property", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "uploaded_file_id": { + "name": "uploaded_file_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lodged'" + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "uprn_source": { + "name": "uprn_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_reference": { + "name": "report_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assessment_type": { + "name": "assessment_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sap_version": { + "name": "sap_version", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "schema_type": { + "name": "schema_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_versions_original": { + "name": "schema_versions_original", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calculation_software_version": { + "name": "calculation_software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_1": { + "name": "address_line_1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address_line_2": { + "name": "address_line_2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "post_town": { + "name": "post_town", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "region_code": { + "name": "region_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dwelling_type": { + "name": "dwelling_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "built_form": { + "name": "built_form", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenure": { + "name": "tenure", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inspection_date": { + "name": "inspection_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completion_date": { + "name": "completion_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "registration_date": { + "name": "registration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_floor_area_m2": { + "name": "total_floor_area_m2", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "measurement_type": { + "name": "measurement_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "solar_water_heating": { + "name": "solar_water_heating", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "has_hot_water_cylinder": { + "name": "has_hot_water_cylinder", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "has_fixed_air_conditioning": { + "name": "has_fixed_air_conditioning", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "has_conservatory": { + "name": "has_conservatory", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_heated_separate_conservatory": { + "name": "has_heated_separate_conservatory", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "conservatory_type": { + "name": "conservatory_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conservatory_floor_area_m2": { + "name": "conservatory_floor_area_m2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "conservatory_glazed_perimeter_m": { + "name": "conservatory_glazed_perimeter_m", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "conservatory_double_glazed": { + "name": "conservatory_double_glazed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "conservatory_thermally_separated": { + "name": "conservatory_thermally_separated", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "conservatory_room_height_storeys": { + "name": "conservatory_room_height_storeys", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "door_count": { + "name": "door_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wet_rooms_count": { + "name": "wet_rooms_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "extensions_count": { + "name": "extensions_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "heated_rooms_count": { + "name": "heated_rooms_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "open_chimneys_count": { + "name": "open_chimneys_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "habitable_rooms_count": { + "name": "habitable_rooms_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "insulated_door_count": { + "name": "insulated_door_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cfl_fixed_lighting_bulbs_count": { + "name": "cfl_fixed_lighting_bulbs_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "led_fixed_lighting_bulbs_count": { + "name": "led_fixed_lighting_bulbs_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "incandescent_fixed_lighting_bulbs_count": { + "name": "incandescent_fixed_lighting_bulbs_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blocked_chimneys_count": { + "name": "blocked_chimneys_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "draughtproofed_door_count": { + "name": "draughtproofed_door_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "energy_rating_average": { + "name": "energy_rating_average", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "low_energy_fixed_lighting_bulbs_count": { + "name": "low_energy_fixed_lighting_bulbs_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fixed_lighting_outlets_count": { + "name": "fixed_lighting_outlets_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "low_energy_fixed_lighting_outlets_count": { + "name": "low_energy_fixed_lighting_outlets_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_storeys": { + "name": "number_of_storeys", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "any_unheated_rooms": { + "name": "any_unheated_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hydro": { + "name": "hydro", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "photovoltaic_array": { + "name": "photovoltaic_array", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "waste_water_heat_recovery": { + "name": "waste_water_heat_recovery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pressure_test": { + "name": "pressure_test", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pressure_test_certificate_number": { + "name": "pressure_test_certificate_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent_draughtproofed": { + "name": "percent_draughtproofed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "insulated_door_u_value": { + "name": "insulated_door_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "multiple_glazed_proportion": { + "name": "multiple_glazed_proportion", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "windows_transmission_u_value": { + "name": "windows_transmission_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "windows_transmission_data_source": { + "name": "windows_transmission_data_source", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "windows_transmission_solar_transmittance": { + "name": "windows_transmission_solar_transmittance", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_gas_connection_available": { + "name": "energy_gas_connection_available", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "energy_meter_type": { + "name": "energy_meter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_pv_battery_count": { + "name": "energy_pv_battery_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "energy_wind_turbines_count": { + "name": "energy_wind_turbines_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "energy_gas_smart_meter_present": { + "name": "energy_gas_smart_meter_present", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "energy_is_dwelling_export_capable": { + "name": "energy_is_dwelling_export_capable", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "energy_wind_turbines_terrain_type": { + "name": "energy_wind_turbines_terrain_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_electricity_smart_meter_present": { + "name": "energy_electricity_smart_meter_present", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "energy_pv_connection": { + "name": "energy_pv_connection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "energy_pv_percent_roof_area": { + "name": "energy_pv_percent_roof_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "energy_pv_battery_capacity": { + "name": "energy_pv_battery_capacity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_wind_turbine_hub_height": { + "name": "energy_wind_turbine_hub_height", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_wind_turbine_rotor_diameter": { + "name": "energy_wind_turbine_rotor_diameter", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_pv_diverter_present": { + "name": "energy_pv_diverter_present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "heating_cylinder_size": { + "name": "heating_cylinder_size", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_water_heating_code": { + "name": "heating_water_heating_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_water_heating_fuel": { + "name": "heating_water_heating_fuel", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_immersion_heating_type": { + "name": "heating_immersion_heating_type", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_insulation_type": { + "name": "heating_cylinder_insulation_type", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_thermostat": { + "name": "heating_cylinder_thermostat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_secondary_fuel_type": { + "name": "heating_secondary_fuel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_secondary_heating_type": { + "name": "heating_secondary_heating_type", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_insulation_thickness_mm": { + "name": "heating_cylinder_insulation_thickness_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_volume_measured_l": { + "name": "heating_cylinder_volume_measured_l", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_wwhrs_index_number_1": { + "name": "heating_wwhrs_index_number_1", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_wwhrs_index_number_2": { + "name": "heating_wwhrs_index_number_2", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_shower_outlet_type": { + "name": "heating_shower_outlet_type", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_shower_wwhrs": { + "name": "heating_shower_wwhrs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_type": { + "name": "ventilation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ventilation_draught_lobby": { + "name": "ventilation_draught_lobby", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ventilation_pressure_test": { + "name": "ventilation_pressure_test", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ventilation_open_flues_count": { + "name": "ventilation_open_flues_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_closed_flues_count": { + "name": "ventilation_closed_flues_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_boiler_flues_count": { + "name": "ventilation_boiler_flues_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_other_flues_count": { + "name": "ventilation_other_flues_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_extract_fans_count": { + "name": "ventilation_extract_fans_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_passive_vents_count": { + "name": "ventilation_passive_vents_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_flueless_gas_fires_count": { + "name": "ventilation_flueless_gas_fires_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_in_pcdf_database": { + "name": "ventilation_in_pcdf_database", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "mechanical_ventilation": { + "name": "mechanical_ventilation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_duct_type": { + "name": "mechanical_vent_duct_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_duct_placement": { + "name": "mechanical_vent_duct_placement", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_duct_insulation": { + "name": "mechanical_vent_duct_insulation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_ventilation_index_number": { + "name": "mechanical_ventilation_index_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_measured_installation": { + "name": "mechanical_vent_measured_installation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_duct_insulation_level": { + "name": "mechanical_vent_duct_insulation_level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "addendum_stone_walls": { + "name": "addendum_stone_walls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "addendum_system_build": { + "name": "addendum_system_build", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "addendum_numbers": { + "name": "addendum_numbers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_number_baths": { + "name": "heating_number_baths", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_number_baths_wwhrs": { + "name": "heating_number_baths_wwhrs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_electric_shower_count": { + "name": "heating_electric_shower_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_mixer_shower_count": { + "name": "heating_mixer_shower_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_present": { + "name": "ventilation_present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ventilation_sheltered_sides": { + "name": "ventilation_sheltered_sides", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_has_suspended_timber_floor": { + "name": "ventilation_has_suspended_timber_floor", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ventilation_suspended_timber_floor_sealed": { + "name": "ventilation_suspended_timber_floor_sealed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ventilation_has_draught_lobby": { + "name": "ventilation_has_draught_lobby", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ventilation_air_permeability_ap4_m3_h_m2": { + "name": "ventilation_air_permeability_ap4_m3_h_m2", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ventilation_air_permeability_ap50_m3_h_m2": { + "name": "ventilation_air_permeability_ap50_m3_h_m2", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ventilation_mechanical_ventilation_kind": { + "name": "ventilation_mechanical_ventilation_kind", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_epc_property_property_portfolio": { + "name": "uq_epc_property_property_portfolio", + "columns": [ + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ix_epc_property_property_source": { + "name": "ix_epc_property_property_source", + "columns": [ + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "epc_property_property_id_property_id_fk": { + "name": "epc_property_property_id_property_id_fk", + "tableFrom": "epc_property", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "epc_property_portfolio_id_portfolio_id_fk": { + "name": "epc_property_portfolio_id_portfolio_id_fk", + "tableFrom": "epc_property", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "epc_property_uploaded_file_id_uploaded_files_id_fk": { + "name": "epc_property_uploaded_file_id_uploaded_files_id_fk", + "tableFrom": "epc_property", + "tableTo": "uploaded_files", + "columnsFrom": [ + "uploaded_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "epc_property_uploaded_file_id_unique": { + "name": "epc_property_uploaded_file_id_unique", + "nullsNotDistinct": false, + "columns": [ + "uploaded_file_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_property_energy_performance": { + "name": "epc_property_energy_performance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "energy_rating_current": { + "name": "energy_rating_current", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_current": { + "name": "energy_consumption_current", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "environmental_impact_current": { + "name": "environmental_impact_current", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_cost_current": { + "name": "heating_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_current": { + "name": "lighting_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_current": { + "name": "hot_water_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions_current": { + "name": "co2_emissions_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions_current_per_floor_area": { + "name": "co2_emissions_current_per_floor_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_energy_efficiency_band": { + "name": "current_energy_efficiency_band", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_rating_potential": { + "name": "energy_rating_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_potential": { + "name": "energy_consumption_potential", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "environmental_impact_potential": { + "name": "environmental_impact_potential", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_cost_potential": { + "name": "heating_cost_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_potential": { + "name": "lighting_cost_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_potential": { + "name": "hot_water_cost_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions_potential": { + "name": "co2_emissions_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "potential_energy_efficiency_band": { + "name": "potential_energy_efficiency_band", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_property_energy_performance_epc_property_id_epc_property_id_fk": { + "name": "epc_property_energy_performance_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_property_energy_performance", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "epc_property_energy_performance_epc_property_id_unique": { + "name": "epc_property_energy_performance_epc_property_id_unique", + "nullsNotDistinct": false, + "columns": [ + "epc_property_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_renewable_heat_incentive": { + "name": "epc_renewable_heat_incentive", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "space_heating_kwh": { + "name": "space_heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "water_heating_kwh": { + "name": "water_heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "impact_of_loft_insulation_kwh": { + "name": "impact_of_loft_insulation_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "impact_of_cavity_insulation_kwh": { + "name": "impact_of_cavity_insulation_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "impact_of_solid_wall_insulation_kwh": { + "name": "impact_of_solid_wall_insulation_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_renewable_heat_incentive_epc_property_id_epc_property_id_fk": { + "name": "epc_renewable_heat_incentive_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_renewable_heat_incentive", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "epc_renewable_heat_incentive_epc_property_id_unique": { + "name": "epc_renewable_heat_incentive_epc_property_id_unique", + "nullsNotDistinct": false, + "columns": [ + "epc_property_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_window": { + "name": "epc_window", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "glazing_gap": { + "name": "glazing_gap", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "orientation": { + "name": "orientation", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "window_type": { + "name": "window_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "glazing_type": { + "name": "glazing_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "window_width": { + "name": "window_width", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "window_height": { + "name": "window_height", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "draught_proofed": { + "name": "draught_proofed", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "window_location": { + "name": "window_location", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "window_wall_type": { + "name": "window_wall_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "permanent_shutters_present": { + "name": "permanent_shutters_present", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "frame_material": { + "name": "frame_material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame_factor": { + "name": "frame_factor", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "permanent_shutters_insulated": { + "name": "permanent_shutters_insulated", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transmission_u_value": { + "name": "transmission_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "transmission_data_source": { + "name": "transmission_data_source", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transmission_solar_transmittance": { + "name": "transmission_solar_transmittance", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_window_epc_property_id_epc_property_id_fk": { + "name": "epc_window_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_window", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.non_intrusive_survey": { + "name": "non_intrusive_survey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "survey_date": { + "name": "survey_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "surveyor": { + "name": "surveyor", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.non_intrusive_survey_notes": { + "name": "non_intrusive_survey_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "survey_id": { + "name": "survey_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk": { + "name": "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk", + "tableFrom": "non_intrusive_survey_notes", + "tableTo": "non_intrusive_survey", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property": { + "name": "property", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "creation_status": { + "name": "creation_status", + "type": "creation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landlord_property_id": { + "name": "landlord_property_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "building_reference_number": { + "name": "building_reference_number", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_inputted_address": { + "name": "user_inputted_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_inputted_postcode": { + "name": "user_inputted_postcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lexiscore": { + "name": "lexiscore", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "has_pre_condition_report": { + "name": "has_pre_condition_report", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_recommendations": { + "name": "has_recommendations", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "built_form": { + "name": "built_form", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_authority": { + "name": "local_authority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency": { + "name": "constituency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number_of_rooms": { + "name": "number_of_rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year_built": { + "name": "year_built", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenure": { + "name": "tenure", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_epc_rating": { + "name": "current_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "current_sap_points": { + "name": "current_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_valuation": { + "name": "current_valuation", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_sap_point_adjustment": { + "name": "installed_measures_sap_point_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_sap_points_adjusted_for_installed_measures": { + "name": "is_sap_points_adjusted_for_installed_measures", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "original_sap_points": { + "name": "original_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lodged_sap_points": { + "name": "lodged_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lodged_epc_rating": { + "name": "lodged_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "marked_for_deletion": { + "name": "marked_for_deletion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "uq_property_portfolio_uprn": { + "name": "uq_property_portfolio_uprn", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"property\".\"uprn\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "ix_property_portfolio": { + "name": "ix_property_portfolio", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_portfolio_id_portfolio_id_fk": { + "name": "property_portfolio_id_portfolio_id_fk", + "tableFrom": "property", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_baseline_performance": { + "name": "property_baseline_performance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "lodged_sap_score": { + "name": "lodged_sap_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lodged_epc_band": { + "name": "lodged_epc_band", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "lodged_co2_emissions_t_per_yr": { + "name": "lodged_co2_emissions_t_per_yr", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lodged_primary_energy_intensity_kwh_per_m2_yr": { + "name": "lodged_primary_energy_intensity_kwh_per_m2_yr", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "effective_sap_score": { + "name": "effective_sap_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "effective_epc_band": { + "name": "effective_epc_band", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "effective_co2_emissions_t_per_yr": { + "name": "effective_co2_emissions_t_per_yr", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "effective_primary_energy_intensity_kwh_per_m2_yr": { + "name": "effective_primary_energy_intensity_kwh_per_m2_yr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rebaseline_reason": { + "name": "rebaseline_reason", + "type": "rebaseline_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "space_heating_kwh": { + "name": "space_heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "water_heating_kwh": { + "name": "water_heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "fuel_rates_period": { + "name": "fuel_rates_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_kwh": { + "name": "heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "heating_cost_gbp": { + "name": "heating_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_kwh": { + "name": "hot_water_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_gbp": { + "name": "hot_water_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_kwh": { + "name": "lighting_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_gbp": { + "name": "lighting_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "appliances_kwh": { + "name": "appliances_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "appliances_cost_gbp": { + "name": "appliances_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cooking_kwh": { + "name": "cooking_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cooking_cost_gbp": { + "name": "cooking_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "pumps_fans_kwh": { + "name": "pumps_fans_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "pumps_fans_cost_gbp": { + "name": "pumps_fans_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cooling_kwh": { + "name": "cooling_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cooling_cost_gbp": { + "name": "cooling_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "standing_charges_gbp": { + "name": "standing_charges_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "seg_credit_gbp": { + "name": "seg_credit_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_annual_bill_gbp": { + "name": "total_annual_bill_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "property_baseline_performance_property_id_property_id_fk": { + "name": "property_baseline_performance_property_id_property_id_fk", + "tableFrom": "property_baseline_performance", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "property_baseline_performance_property_id_unique": { + "name": "property_baseline_performance_property_id_unique", + "nullsNotDistinct": false, + "columns": [ + "property_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_epc": { + "name": "property_details_epc", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "full_address": { + "name": "full_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lodgement_date": { + "name": "lodgement_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_expired": { + "name": "is_expired", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "total_floor_area": { + "name": "total_floor_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "walls": { + "name": "walls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "walls_rating": { + "name": "walls_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "roof": { + "name": "roof", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roof_rating": { + "name": "roof_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_rating": { + "name": "floor_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "windows": { + "name": "windows", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "windows_rating": { + "name": "windows_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "heating": { + "name": "heating", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_rating": { + "name": "heating_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "heating_controls": { + "name": "heating_controls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_controls_rating": { + "name": "heating_controls_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "hot_water": { + "name": "hot_water", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hot_water_rating": { + "name": "hot_water_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "lighting": { + "name": "lighting", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lighting_rating": { + "name": "lighting_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "mainfuel": { + "name": "mainfuel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ventilation": { + "name": "ventilation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "solar_pv": { + "name": "solar_pv", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "solar_hot_water": { + "name": "solar_hot_water", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "wind_turbine": { + "name": "wind_turbine", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "floor_height": { + "name": "floor_height", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_heated_rooms": { + "name": "number_heated_rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heat_loss_corridor": { + "name": "heat_loss_corridor", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "unheated_corridor_length": { + "name": "unheated_corridor_length", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_open_fireplaces": { + "name": "number_of_open_fireplaces", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_extensions": { + "name": "number_of_extensions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_storeys": { + "name": "number_of_storeys", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mains_gas": { + "name": "mains_gas", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "energy_tariff": { + "name": "energy_tariff", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_energy_consumption": { + "name": "primary_energy_consumption", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions": { + "name": "co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_energy_demand": { + "name": "current_energy_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_energy_demand_heating_hotwater": { + "name": "current_energy_demand_heating_hotwater", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "sap_05_overwritten": { + "name": "sap_05_overwritten", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "sap_05_score": { + "name": "sap_05_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "sap_05_epc_rating": { + "name": "sap_05_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "heating_cost_current": { + "name": "heating_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_current": { + "name": "hot_water_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_current": { + "name": "lighting_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "appliances_cost_current": { + "name": "appliances_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "gas_standing_charge": { + "name": "gas_standing_charge", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "electricity_standing_charge": { + "name": "electricity_standing_charge", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "original_co2_emissions": { + "name": "original_co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "original_primary_energy_consumption": { + "name": "original_primary_energy_consumption", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "original_current_energy_demand": { + "name": "original_current_energy_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "original_current_energy_demand_heating_hotwater": { + "name": "original_current_energy_demand_heating_hotwater", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_co2_adjustment": { + "name": "installed_measures_co2_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_energy_demand_adjustment": { + "name": "installed_measures_energy_demand_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_total_energy_bill_adjustment": { + "name": "installed_measures_total_energy_bill_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_heat_demand_adjustment": { + "name": "installed_measures_heat_demand_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_epc_adjusted_for_installed_measures": { + "name": "is_epc_adjusted_for_installed_measures", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "lodged_co2_emissions": { + "name": "lodged_co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lodged_heat_demand": { + "name": "lodged_heat_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "has_been_remodelled": { + "name": "has_been_remodelled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "environment_impact_current": { + "name": "environment_impact_current", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_property_details_epc_property_portfolio": { + "name": "uq_property_details_epc_property_portfolio", + "columns": [ + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_details_epc_property_id_property_id_fk": { + "name": "property_details_epc_property_id_property_id_fk", + "tableFrom": "property_details_epc", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_details_epc_portfolio_id_portfolio_id_fk": { + "name": "property_details_epc_portfolio_id_portfolio_id_fk", + "tableFrom": "property_details_epc", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_meter": { + "name": "property_details_meter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "energy_supplier": { + "name": "energy_supplier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gas_supplier": { + "name": "gas_supplier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meter_reading_total": { + "name": "meter_reading_total", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "meter_reading_electricity": { + "name": "meter_reading_electricity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "meter_reading_gas": { + "name": "meter_reading_gas", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_spatial": { + "name": "property_details_spatial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "x_coordinate": { + "name": "x_coordinate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "y_coordinate": { + "name": "y_coordinate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "conservation_status": { + "name": "conservation_status", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_listed_building": { + "name": "is_listed_building", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_heritage_building": { + "name": "is_heritage_building", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_property_details_spatial_uprn": { + "name": "uq_property_details_spatial_uprn", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_targets": { + "name": "property_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "epc": { + "name": "epc", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "heat_demand": { + "name": "heat_demand", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "property_targets_property_id_property_id_fk": { + "name": "property_targets_property_id_property_id_fk", + "tableFrom": "property_targets", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_targets_portfolio_id_portfolio_id_fk": { + "name": "property_targets_portfolio_id_portfolio_id_fk", + "tableFrom": "property_targets", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.installed_measure": { + "name": "installed_measure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "measure_type": { + "name": "measure_type", + "type": "measure_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "sap_points": { + "name": "sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "carbon_savings": { + "name": "carbon_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "kwh_savings": { + "name": "kwh_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bill_savings": { + "name": "bill_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "heat_demand_savings": { + "name": "heat_demand_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_installed_measure_uprn": { + "name": "idx_installed_measure_uprn", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_measure_uprn_active": { + "name": "idx_installed_measure_uprn_active", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"installed_measure\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_measure_measure_type": { + "name": "idx_installed_measure_measure_type", + "columns": [ + { + "expression": "measure_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_measure_uprn_measure": { + "name": "idx_installed_measure_uprn_measure", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "measure_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"installed_measure\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan": { + "name": "plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "valuation_increase_lower_bound": { + "name": "valuation_increase_lower_bound", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_increase_upper_bound": { + "name": "valuation_increase_upper_bound", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_increase_average": { + "name": "valuation_increase_average", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "post_sap_points": { + "name": "post_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "post_epc_rating": { + "name": "post_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "post_co2_emissions": { + "name": "post_co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_savings": { + "name": "co2_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "post_energy_bill": { + "name": "post_energy_bill", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_bill_savings": { + "name": "energy_bill_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "post_energy_consumption": { + "name": "post_energy_consumption", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_savings": { + "name": "energy_consumption_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_post_retrofit": { + "name": "valuation_post_retrofit", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_increase": { + "name": "valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost_of_works": { + "name": "cost_of_works", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "contingency_cost": { + "name": "contingency_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "plan_type": { + "name": "plan_type", + "type": "plan_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_plan_portfolio_scenario": { + "name": "idx_plan_portfolio_scenario", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plan_latest_per_property": { + "name": "idx_plan_latest_per_property", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_portfolio_id_portfolio_id_fk": { + "name": "plan_portfolio_id_portfolio_id_fk", + "tableFrom": "plan", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_property_id_property_id_fk": { + "name": "plan_property_id_property_id_fk", + "tableFrom": "plan", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_scenario_id_scenario_id_fk": { + "name": "plan_scenario_id_scenario_id_fk", + "tableFrom": "plan", + "tableTo": "scenario", + "columnsFrom": [ + "scenario_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan_recommendations": { + "name": "plan_recommendations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "recommendation_id": { + "name": "recommendation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_plan_recommendations_plan_id": { + "name": "idx_plan_recommendations_plan_id", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plan_recommendations_plan_rec": { + "name": "idx_plan_recommendations_plan_rec", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recommendation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plan_recommendations_recommendation_id": { + "name": "idx_plan_recommendations_recommendation_id", + "columns": [ + { + "expression": "recommendation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_recommendations_plan_id_plan_id_fk": { + "name": "plan_recommendations_plan_id_plan_id_fk", + "tableFrom": "plan_recommendations", + "tableTo": "plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_recommendations_recommendation_id_recommendation_id_fk": { + "name": "plan_recommendations_recommendation_id_recommendation_id_fk", + "tableFrom": "plan_recommendations", + "tableTo": "recommendation", + "columnsFrom": [ + "recommendation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendation": { + "name": "recommendation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "material_id": { + "name": "material_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "material_quantity": { + "name": "material_quantity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "material_quantity_unit": { + "name": "material_quantity_unit", + "type": "unit_quantity", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "material_depth": { + "name": "material_depth", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_type": { + "name": "measure_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "contingency_cost": { + "name": "contingency_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "starting_u_value": { + "name": "starting_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "new_u_value": { + "name": "new_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "sap_points": { + "name": "sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "heat_demand": { + "name": "heat_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "kwh_savings": { + "name": "kwh_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rental_yield_increase": { + "name": "rental_yield_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "already_installed": { + "name": "already_installed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "recommendation_property_id_idx": { + "name": "recommendation_property_id_idx", + "columns": [ + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_recommendation_plan_id": { + "name": "idx_recommendation_plan_id", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_recommendation_active_defaults": { + "name": "idx_recommendation_active_defaults", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"recommendation\".\"default\" = true AND \"recommendation\".\"already_installed\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_recommendation_active_id_property": { + "name": "idx_recommendation_active_id_property", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"recommendation\".\"default\" = true AND \"recommendation\".\"already_installed\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_recommendation_material_id": { + "name": "idx_recommendation_material_id", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recommendation_property_id_property_id_fk": { + "name": "recommendation_property_id_property_id_fk", + "tableFrom": "recommendation", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "recommendation_plan_id_plan_id_fk": { + "name": "recommendation_plan_id_plan_id_fk", + "tableFrom": "recommendation", + "tableTo": "plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recommendation_material_id_material_id_fk": { + "name": "recommendation_material_id_material_id_fk", + "tableFrom": "recommendation", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendation_materials": { + "name": "recommendation_materials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "recommendation_id": { + "name": "recommendation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "depth": { + "name": "depth", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "quantity_unit": { + "name": "quantity_unit", + "type": "unit_quantity", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recommendation_materials_recommendation_id_idx": { + "name": "recommendation_materials_recommendation_id_idx", + "columns": [ + { + "expression": "recommendation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recommendation_materials_recommendation_id_recommendation_id_fk": { + "name": "recommendation_materials_recommendation_id_recommendation_id_fk", + "tableFrom": "recommendation_materials", + "tableTo": "recommendation", + "columnsFrom": [ + "recommendation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recommendation_materials_material_id_material_id_fk": { + "name": "recommendation_materials_material_id_material_id_fk", + "tableFrom": "recommendation_materials", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenario": { + "name": "scenario", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "budget": { + "name": "budget", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "housing_type": { + "name": "housing_type", + "type": "housing_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "goal", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal_value": { + "name": "goal_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ashp_cop": { + "name": "ashp_cop", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 2.8 + }, + "trigger_file_path": { + "name": "trigger_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "already_installed_file_path": { + "name": "already_installed_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patches_file_path": { + "name": "patches_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "non_invasive_recommendations_file_path": { + "name": "non_invasive_recommendations_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exclusions": { + "name": "exclusions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "multi_plan": { + "name": "multi_plan", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "contingency": { + "name": "contingency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "funding": { + "name": "funding", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_pre_retrofit": { + "name": "epc_breakdown_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_post_retrofit": { + "name": "epc_breakdown_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number_of_properties": { + "name": "number_of_properties", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "n_units_to_retrofit": { + "name": "n_units_to_retrofit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_pre_retrofit": { + "name": "co2_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_post_retrofit": { + "name": "co2_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_pre_retrofit": { + "name": "energy_bill_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_post_retrofit": { + "name": "energy_bill_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_pre_retrofit": { + "name": "energy_consumption_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_post_retrofit": { + "name": "energy_consumption_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_improvement_per_unit": { + "name": "valuation_improvement_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_unit": { + "name": "cost_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_co2_saved": { + "name": "cost_per_co2_saved", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_sap_point": { + "name": "cost_per_sap_point", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_return_on_investment": { + "name": "valuation_return_on_investment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "scenario_portfolio_id_portfolio_id_fk": { + "name": "scenario_portfolio_id_portfolio_id_fk", + "tableFrom": "scenario", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_removal_requests": { + "name": "property_removal_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'removal'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "requested_by": { + "name": "requested_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "original_batch": { + "name": "original_batch", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_removal_requests_deal_id": { + "name": "idx_removal_requests_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_removal_requests_portfolio_id": { + "name": "idx_removal_requests_portfolio_id", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_removal_requests_portfolio_id_portfolio_id_fk": { + "name": "property_removal_requests_portfolio_id_portfolio_id_fk", + "tableFrom": "property_removal_requests", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_removal_requests_requested_by_user_id_fk": { + "name": "property_removal_requests_requested_by_user_id_fk", + "tableFrom": "property_removal_requests", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_removal_requests_reviewed_by_user_id_fk": { + "name": "property_removal_requests_reviewed_by_user_id_fk", + "tableFrom": "property_removal_requests", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.solar": { + "name": "solar", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "google_api_response": { + "name": "google_api_response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.solar_scenario": { + "name": "solar_scenario", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "solar_id": { + "name": "solar_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scenario_type": { + "name": "scenario_type", + "type": "scenario_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "number_panels": { + "name": "number_panels", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "array_kwhp": { + "name": "array_kwhp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lifetime_dc_kwh": { + "name": "lifetime_dc_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "yearly_dc_kwh": { + "name": "yearly_dc_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "lifetime_ac_kwh": { + "name": "lifetime_ac_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "yearly_ac_kwh": { + "name": "yearly_ac_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "expected_payback_years": { + "name": "expected_payback_years", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "panelled_roof_area": { + "name": "panelled_roof_area", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "solar_scenario_solar_id_solar_id_fk": { + "name": "solar_scenario_solar_id_solar_id_fk", + "tableFrom": "solar_scenario", + "tableTo": "solar", + "columnsFrom": [ + "solar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.survey_requests": { + "name": "survey_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "survey_type": { + "name": "survey_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "requested_by": { + "name": "requested_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "fulfilled_at": { + "name": "fulfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_survey_requests_deal_id": { + "name": "idx_survey_requests_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_survey_requests_portfolio_id": { + "name": "idx_survey_requests_portfolio_id", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "survey_requests_portfolio_id_portfolio_id_fk": { + "name": "survey_requests_portfolio_id_portfolio_id_fk", + "tableFrom": "survey_requests", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "survey_requests_requested_by_user_id_fk": { + "name": "survey_requests_requested_by_user_id_fk", + "tableFrom": "survey_requests", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sub_task": { + "name": "sub_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_started": { + "name": "job_started", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "job_completed": { + "name": "job_completed", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'In Progress'" + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_logs_url": { + "name": "cloud_logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sub_task_task_id_tasks_id_fk": { + "name": "sub_task_task_id_tasks_id_fk", + "tableFrom": "sub_task", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_source": { + "name": "task_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_started": { + "name": "job_started", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "job_completed": { + "name": "job_completed", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'In Progress'" + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_org_id_organisation_id_fk": { + "name": "team_org_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_user_id_user_id_fk": { + "name": "team_members_user_id_user_id_fk", + "tableFrom": "team_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "team_members_team_id_team_id_fk": { + "name": "team_members_team_id_team_id_fk", + "tableFrom": "team_members", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_portfolio_permissions": { + "name": "team_portfolio_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_portfolio_permissions_team_id_team_id_fk": { + "name": "team_portfolio_permissions_team_id_team_id_fk", + "tableFrom": "team_portfolio_permissions", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "team_portfolio_permissions_portfolio_id_portfolio_id_fk": { + "name": "team_portfolio_permissions_portfolio_id_portfolio_id_fk", + "tableFrom": "team_portfolio_permissions", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.uploaded_files": { + "name": "uploaded_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "s3_file_bucket": { + "name": "s3_file_bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_file_key": { + "name": "s3_file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_upload_timestamp": { + "name": "s3_upload_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "landlord_property_id": { + "name": "landlord_property_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hubspot_listing_id": { + "name": "hubspot_listing_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "file_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "file_source": { + "name": "file_source", + "type": "file_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "uploaded_files_uploaded_by_user_id_fk": { + "name": "uploaded_files_uploaded_by_user_id_fk", + "tableFrom": "uploaded_files", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_defined_deal_measures": { + "name": "user_defined_deal_measures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "user_defined_deal_measure_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pushed_at": { + "name": "pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmed_in_hubspot_at": { + "name": "confirmed_in_hubspot_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_defined_deal_measures_deal_id": { + "name": "idx_user_defined_deal_measures_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_defined_deal_measures_source": { + "name": "idx_user_defined_deal_measures_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_defined_deal_measures_created_by_user_id_user_id_fk": { + "name": "user_defined_deal_measures_created_by_user_id_user_id_fk", + "tableFrom": "user_defined_deal_measures", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_portfolio_config": { + "name": "user_portfolio_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_portfolio_config_folder": { + "name": "idx_user_portfolio_config_folder", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_portfolio_config_user_id_user_id_fk": { + "name": "user_portfolio_config_user_id_user_id_fk", + "tableFrom": "user_portfolio_config", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_portfolio_config_portfolio_id_portfolio_id_fk": { + "name": "user_portfolio_config_portfolio_id_portfolio_id_fk", + "tableFrom": "user_portfolio_config", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_portfolio_config_folder_owner_fk": { + "name": "user_portfolio_config_folder_owner_fk", + "tableFrom": "user_portfolio_config", + "tableTo": "user_portfolio_folders", + "columnsFrom": [ + "folder_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_portfolio_config_user_portfolio_unique": { + "name": "user_portfolio_config_user_portfolio_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "portfolio_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_portfolio_folders": { + "name": "user_portfolio_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_portfolio_folders_user_position": { + "name": "idx_user_portfolio_folders_user_position", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_portfolio_folders_user_id_user_id_fk": { + "name": "user_portfolio_folders_user_id_user_id_fk", + "tableFrom": "user_portfolio_folders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_portfolio_folders_id_user_unique": { + "name": "user_portfolio_folders_id_user_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authRateLimits": { + "name": "authRateLimits", + "schema": "", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "authRateLimits_scope_key_pk": { + "name": "authRateLimits_scope_key_pk", + "columns": [ + "scope", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "firstName": { + "name": "firstName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "oauth_id": { + "name": "oauth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login": { + "name": "last_login", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_type": { + "name": "user_type", + "type": "user_profiles_user_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "property_count": { + "name": "property_count", + "type": "user_profiles_property_count", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "goals": { + "name": "goals", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "referral_source": { + "name": "referral_source", + "type": "user_profiles_referral_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "nrla_membership_id": { + "name": "nrla_membership_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "accepted_privacy": { + "name": "accepted_privacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "accepted_privacy_at": { + "name": "accepted_privacy_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "marketing_opt_in": { + "name": "marketing_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "marketing_opt_in_at": { + "name": "marketing_opt_in_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profiles_user_id_user_id_fk": { + "name": "user_profiles_user_id_user_id_fk", + "tableFrom": "user_profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.whlg": { + "name": "whlg", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.aspect_type": { + "name": "aspect_type", + "schema": "public", + "values": [ + "material", + "condition", + "type", + "area", + "configuration", + "presence", + "risk", + "severity", + "location", + "finish", + "insulation", + "pointing", + "spalling", + "lintels", + "cladding", + "category", + "quantity", + "adequacy", + "rating", + "strategy", + "extent", + "distribution", + "structure", + "covering", + "fire_rating", + "external_decoration", + "work_required", + "age_band", + "construction_type", + "classification", + "system" + ] + }, + "public.element_type": { + "name": "element_type", + "schema": "public", + "values": [ + "property", + "property_construction_type", + "property_classification", + "property_age_band", + "storey_count", + "floor_level", + "floor_level_front_door", + "accessible_housing_register", + "asbestos", + "quality_standard", + "ccu", + "passenger_lift", + "stairlift", + "disabled_hoist_tracking", + "disabled_facilities", + "steps_to_front_door", + "roof", + "pitched_roof_covering", + "flat_roof_covering", + "rainwater_goods", + "loft_insulation", + "porch_canopy", + "chimney", + "fascia", + "soffit", + "fascia_soffit_bargeboards", + "gutters", + "store_roof", + "garage_roof", + "garage_and_store_roof", + "external_wall", + "external_noise_insulation", + "primary_wall", + "secondary_wall", + "downpipes", + "external_decoration", + "cladding", + "spandrel_panels", + "garage_walls", + "party_wall_fire_break", + "external_brickwork_pointing", + "internal_downpipes_external_area", + "external_windows", + "communal_windows", + "secondary_glazing", + "store_windows", + "garage_windows", + "garage_and_store_windows", + "external_door", + "front_door", + "rear_door", + "store_door", + "garage_door", + "garage_and_store_door", + "communal_entrance_door", + "main_door", + "block_entrance_door", + "lintel", + "patio_french_door", + "door_entry_handset", + "paths_and_hardstandings", + "parking_areas", + "boundary_walls", + "front_fencing", + "rear_fencing", + "side_fencing", + "rear_gate", + "front_gate", + "gates", + "retaining_walls", + "private_balcony", + "balcony_balustrade", + "outbuildings", + "garage_structure", + "paving", + "roads", + "soil_and_vent", + "solar_thermals", + "drop_kerb", + "outbuilding_overhaul", + "external_structural_defects", + "access_ramp", + "kitchen", + "kitchen_space_layout", + "tenant_installed_kitchen", + "kitchen_extractor_fan", + "bathroom", + "secondary_bathroom", + "secondary_toilet", + "bathroom_extractor_fan", + "additional_wc_or_whb", + "bathroom_remaining_life_source", + "kitchen_remaining_life_source", + "central_heating", + "heating_boiler", + "heating_distribution", + "secondary_heating", + "hot_water_system", + "cold_water_storage", + "heating_system", + "boiler_fuel", + "water_heating", + "programmable_heating", + "community_heating", + "gas_available", + "heat_recovery_units", + "heating_improvements", + "electrical_wiring", + "consumer_unit", + "smoke_detection", + "heat_detection", + "carbon_monoxide_detection", + "fire_door_rating", + "fire_risk_assessment", + "internal_wiring", + "electrics", + "communal_heating", + "communal_boiler", + "communal_electrics", + "communal_fire_alarm", + "communal_emergency_lighting", + "communal_door_entry", + "communal_cctv", + "communal_bin_store", + "communal_bin_store_doors", + "communal_bin_store_walls", + "communal_bin_store_roof", + "communal_refuse_chute", + "communal_floor_covering", + "communal_kitchen", + "communal_bathroom", + "communal_toilets", + "communal_gates", + "communal_lift", + "communal_passenger_lift", + "communal_balcony_walkway", + "communal_entrance", + "communal_internal_decorations", + "communal_internal_floor", + "communal_walkways", + "communal_external_doors", + "communal_stairs", + "communal_aerial", + "communal_aov", + "communal_internal_doors", + "communal_lateral_mains", + "communal_lighting", + "communal_lighting_conductor", + "communal_store_roof", + "communal_store_walls", + "communal_store_doors", + "communal_warden_call_system", + "communal_bms", + "communal_booster_pump", + "communal_dry_riser", + "communal_wet_riser", + "communal_cold_water_storage", + "communal_sprinkler", + "communal_plug_sockets", + "communal_circulation_space", + "ffhh_damp", + "ffhh_hold_and_cold_water", + "ffhh_drainage_lavatories", + "ffhh_neglected", + "ffhh_natural_light", + "ffhh_ventilation", + "ffhh_food_prep_and_washup", + "ffhh_unsafe_layout", + "ffhh_unstable_building", + "hhsrs_damp_and_mould", + "hhsrs_excess_cold", + "hhsrs_excess_heat", + "hhsrs_asbestos_and_mmf", + "hhsrs_biocides", + "hhsrs_carbon_monoxide", + "hhsrs_lead", + "hhsrs_radiation", + "hhsrs_uncombusted_fuel_gas", + "hhsrs_volatile_organic_compounds", + "hhsrs_crowding_and_space", + "hhsrs_entry_by_intruders", + "hhsrs_lighting", + "hhsrs_noise", + "hhsrs_domestic_hygiene_pests_refuse", + "hhsrs_food_safety", + "hhsrs_personal_hygiene_sanitation", + "hhsrs_water_supply", + "hhsrs_falls_associated_with_baths", + "hhsrs_falls_on_level_surfaces", + "hhsrs_falls_on_stairs", + "hhsrs_falls_between_levels", + "hhsrs_electrical_hazards", + "hhsrs_fire", + "hhsrs_flames_hot_surfaces", + "hhsrs_collision_and_entrapment", + "hhsrs_collision_hazards_low_headroom", + "hhsrs_explosions", + "hhsrs_ergonomics", + "hhsrs_structural_collapse", + "hhsrs_amenities" + ] + }, + "public.document_type": { + "name": "document_type", + "schema": "public", + "values": [ + "EPR", + "Condition Report", + "Evidence Report", + "Summary Information", + "Floor Plan", + "Scenario Draft EPC", + "Scenario Site Notes" + ] + }, + "public.scheme": { + "name": "scheme", + "schema": "public", + "values": [ + "eco4", + "gbis", + "whlg", + "none" + ] + }, + "public.inspection_archetype_2": { + "name": "inspection_archetype_2", + "schema": "public", + "values": [ + "detached", + "mid-terrace", + "enclosed mid-terrace", + "end-terrace", + "enclosed end-terrace", + "semi-detached" + ] + }, + "public.inspection_archetype": { + "name": "inspection_archetype", + "schema": "public", + "values": [ + "Bungalow", + "Flat", + "Maisonette", + "House", + "non-domestic" + ] + }, + "public.inspection_borescoped": { + "name": "inspection_borescoped", + "schema": "public", + "values": [ + "yes", + "no", + "refused" + ] + }, + "public.inspections_access_issues": { + "name": "inspections_access_issues", + "schema": "public", + "values": [ + "see notes", + "damp issues", + "foliage on walls", + "bushes against wall", + "trees around/anove property", + "high rise block flats/maisonettes", + "conservatory", + "lean-to", + "garage", + "extension", + "decking", + "shed against wall" + ] + }, + "public.inspections_cladding": { + "name": "inspections_cladding", + "schema": "public", + "values": [ + "none", + "cladded with “sufficient space to fill the wall”", + "cladded with “insufficient space to fill the wall”" + ] + }, + "public.inspections_insulation_material": { + "name": "inspections_insulation_material", + "schema": "public", + "values": [ + "empty 50-90", + "empty 100+", + "empty 30-40", + "empty less than 30", + "loose fibre/wool", + "eps/celo/king", + "fibre batts - with cavity", + "fibre batts - no cavity", + "loose bead", + "glued bead", + "formaldehyde", + "bubble wrap", + "poly chunks" + ] + }, + "public.inspections_rendered": { + "name": "inspections_rendered", + "schema": "public", + "values": [ + "no render", + "rendered with “insufficient” space between dpc and render", + "rendered with “sufficient” space between dpc and render" + ] + }, + "public.inspections_roof_orientation": { + "name": "inspections_roof_orientation", + "schema": "public", + "values": [ + "north", + "east", + "south", + "west", + "north-east", + "north-west", + "south-east", + "south-west", + "n/s split", + "e/w split", + "ne/sw split", + "nw/se split", + "flat roof", + "no roof", + "roof too small", + "already has solar pv" + ] + }, + "public.inspections_tile_hung": { + "name": "inspections_tile_hung", + "schema": "public", + "values": [ + "yes", + "no", + "first floor flats are tile hung" + ] + }, + "public.inspections_wall_construction": { + "name": "inspections_wall_construction", + "schema": "public", + "values": [ + "cavity", + "solid", + "system built", + "timber framed", + "steel framed", + "re-walled cavity", + "mansard pre-fab", + "mansard ewi", + "mansard re-walled" + ] + }, + "public.inspections_wall_insulation": { + "name": "inspections_wall_insulation", + "schema": "public", + "values": [ + "empty cavity", + "filled at build", + "partial", + "retro drilled", + "ewi", + "iwi", + "solid non-cavity", + "system built", + "timber framed", + "steel framed" + ] + }, + "public.built_form_type": { + "name": "built_form_type", + "schema": "public", + "values": [ + "Detached", + "Semi-Detached", + "Mid-Terrace", + "End-Terrace", + "Enclosed Mid-Terrace", + "Enclosed End-Terrace", + "Not Recorded", + "Unknown" + ] + }, + "public.construction_age_band": { + "name": "construction_age_band", + "schema": "public", + "values": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "Unknown" + ] + }, + "public.glazing": { + "name": "glazing", + "schema": "public", + "values": [ + "Single glazing", + "Double glazing, 2002 or later", + "Double glazing, pre-2002", + "Triple glazing, pre-2002", + "Triple glazing, 2002 or later", + "Mixed glazing", + "Secondary glazing", + "Unknown" + ] + }, + "public.main_fuel": { + "name": "main_fuel", + "schema": "public", + "values": [ + "mains gas", + "mains gas (community)", + "electricity", + "electricity (community)", + "LPG (bulk)", + "bottled LPG", + "LPG special condition", + "oil", + "house coal", + "smokeless coal", + "dual fuel (mineral and wood)", + "biomass (community)", + "wood logs", + "Unknown" + ] + }, + "public.main_heating_system": { + "name": "main_heating_system", + "schema": "public", + "values": [ + "Gas boiler, combi", + "Gas boiler, regular", + "Gas CPSU", + "Electric storage heaters, old", + "Electric storage heaters, slimline", + "Electric storage heaters, convector", + "Electric storage heaters, fan", + "Direct-acting electric", + "Electric room heaters", + "Solid fuel room heater, closed", + "Air source heat pump", + "Community heating, boilers", + "Community heating, CHP and boilers", + "Oil room heater, 2000 or later", + "Gas room heater, condensing fire", + "Gas room heater, decorative fuel-effect", + "Gas room heater, flush live-effect", + "Gas room heater, open flue 1980 or later", + "Gas room heater, open flue pre-1980", + "Electric storage heaters, high heat retention", + "Solid fuel room heater, open fire", + "Solid fuel room heater, open fire with back boiler", + "Solid fuel room heater, closed with boiler", + "Electric boiler", + "Electric CPSU", + "Electric underfloor, in concrete slab (off-peak)", + "Electric underfloor, integrated storage and direct-acting", + "Electric underfloor, in screed above insulation", + "Unknown" + ] + }, + "public.override_source": { + "name": "override_source", + "schema": "public", + "values": [ + "classifier", + "user", + "os_places" + ] + }, + "public.property_type": { + "name": "property_type", + "schema": "public", + "values": [ + "House", + "Bungalow", + "Flat", + "Maisonette", + "Park home", + "Unknown" + ] + }, + "public.roof_type": { + "name": "roof_type", + "schema": "public", + "values": [ + "Flat, insulated", + "Flat, insulated (assumed)", + "Flat, limited insulation", + "Flat, limited insulation (assumed)", + "Flat, no insulation", + "Flat, no insulation (assumed)", + "Flat, 12 mm insulation", + "Flat, 25 mm insulation", + "Flat, 50 mm insulation", + "Flat, 75 mm insulation", + "Flat, 100 mm insulation", + "Flat, 125 mm insulation", + "Flat, 150 mm insulation", + "Flat, 175 mm insulation", + "Flat, 200 mm insulation", + "Flat, 225 mm insulation", + "Flat, 250 mm insulation", + "Flat, 270 mm insulation", + "Flat, 300 mm insulation", + "Flat, 350 mm insulation", + "Flat, 400 mm insulation", + "Flat, 400+ mm insulation", + "Pitched, insulated", + "Pitched, insulated (assumed)", + "Pitched, insulated at rafters", + "Pitched, limited insulation", + "Pitched, limited insulation (assumed)", + "Pitched, no insulation", + "Pitched, no insulation (assumed)", + "Pitched, Unknown loft insulation", + "Pitched, 0 mm loft insulation", + "Pitched, 12 mm loft insulation", + "Pitched, 25 mm loft insulation", + "Pitched, 50 mm loft insulation", + "Pitched, 75 mm loft insulation", + "Pitched, 100 mm loft insulation", + "Pitched, 125 mm loft insulation", + "Pitched, 150 mm loft insulation", + "Pitched, 175 mm loft insulation", + "Pitched, 200 mm loft insulation", + "Pitched, 225 mm loft insulation", + "Pitched, 250 mm loft insulation", + "Pitched, 270 mm loft insulation", + "Pitched, 300 mm loft insulation", + "Pitched, 350 mm loft insulation", + "Pitched, 400 mm loft insulation", + "Pitched, 400+ mm loft insulation", + "Roof room(s), insulated", + "Roof room(s), insulated (assumed)", + "Roof room(s), limited insulation", + "Roof room(s), limited insulation (assumed)", + "Roof room(s), no insulation", + "Roof room(s), no insulation (assumed)", + "Roof room(s), ceiling insulated", + "Roof room(s), thatched", + "Roof room(s), thatched with additional insulation", + "Thatched", + "Thatched, with additional insulation", + "(another dwelling above)", + "(same dwelling above)", + "(other premises above)", + "(another premises above)", + "Another Premises Above", + "Unknown" + ] + }, + "public.wall_type": { + "name": "wall_type", + "schema": "public", + "values": [ + "Cavity wall, filled cavity", + "Cavity wall, as built, insulated (assumed)", + "Cavity wall, as built, no insulation (assumed)", + "Cavity wall, as built, partial insulation (assumed)", + "Cavity wall, with internal insulation", + "Cavity wall, with external insulation", + "Cavity wall, filled cavity and internal insulation", + "Cavity wall, filled cavity and external insulation", + "Solid brick, as built, no insulation (assumed)", + "Solid brick, as built, insulated (assumed)", + "Solid brick, as built, partial insulation (assumed)", + "Solid brick, with internal insulation", + "Solid brick, with external insulation", + "Timber frame, as built, no insulation (assumed)", + "Timber frame, as built, insulated (assumed)", + "Timber frame, as built, partial insulation (assumed)", + "Timber frame, with additional insulation", + "Sandstone, as built, no insulation (assumed)", + "Sandstone, as built, insulated (assumed)", + "Sandstone, as built, partial insulation (assumed)", + "Sandstone, with internal insulation", + "Sandstone, with external insulation", + "Granite or whin, as built, no insulation (assumed)", + "Granite or whin, as built, insulated (assumed)", + "Granite or whin, as built, partial insulation (assumed)", + "Granite or whin, with internal insulation", + "Granite or whin, with external insulation", + "System built, as built, no insulation (assumed)", + "System built, as built, insulated (assumed)", + "System built, as built, partial insulation (assumed)", + "System built, with internal insulation", + "System built, with external insulation", + "Park home wall, as built", + "Park home wall, with internal insulation", + "Park home wall, with external insulation", + "Cob, as built", + "Cob, with internal insulation", + "Cob, with external insulation", + "Curtain wall", + "Curtain Wall, as built, no insulation (assumed)", + "Curtain Wall, as built, insulated (assumed)", + "Curtain Wall, filled cavity", + "Curtain Wall, with internal insulation", + "Basement wall", + "Basement wall, as built", + "Unknown" + ] + }, + "public.water_heating": { + "name": "water_heating", + "schema": "public", + "values": [ + "From main system, mains gas", + "From main system, electricity", + "From main system, oil", + "From main system, LPG (bulk)", + "From main system, bottled LPG", + "From main system, house coal", + "Electric immersion, electricity", + "Gas boiler/circulator, mains gas", + "From main system, wood logs", + "From main system, biomass (community)", + "From main system, dual fuel (mineral and wood)", + "From main system, biodiesel (community)", + "Unknown" + ] + }, + "public.cost_unit": { + "name": "cost_unit", + "schema": "public", + "values": [ + "gbp_sq_meter", + "gbp_per_unit", + "gbp_per_m2", + "gbp_per_m" + ] + }, + "public.depth_unit": { + "name": "depth_unit", + "schema": "public", + "values": [ + "mm" + ] + }, + "public.type": { + "name": "type", + "schema": "public", + "values": [ + "suspended_floor_insulation", + "solid_floor_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "cavity_wall_insulation", + "mechanical_ventilation", + "loft_insulation", + "exposed_floor_insulation", + "flat_roof_insulation", + "room_roof_insulation", + "cavity_wall_extraction", + "iwi_wall_demolition", + "iwi_vapour_barrier", + "iwi_redecoration", + "suspended_floor_demolition", + "suspended_floor_redecoration", + "suspended_floor_vapour_barrier", + "solid_floor_demolition", + "solid_floor_preparation", + "solid_floor_vapour_barrier", + "solid_floor_redecoration", + "ewi_wall_demolition", + "ewi_wall_preparation", + "ewi_wall_redecoration", + "low_energy_lighting_installation", + "flat_roof_preparation", + "flat_roof_vapour_barrier", + "flat_roof_waterproofing", + "windows_glazing", + "secondary_glazing", + "double_glazing", + "trickle_vent", + "door_undercut", + "solar_pv", + "solar_battery", + "scaffolding", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "boiler_upgrade", + "roomstat_programmer_trvs", + "time_temperature_zone_control", + "sealing_fireplace" + ] + }, + "public.r_value_unit": { + "name": "r_value_unit", + "schema": "public", + "values": [ + "square_meter_kelvin_per_watt" + ] + }, + "public.size_unit": { + "name": "size_unit", + "schema": "public", + "values": [ + "kWp", + "kW", + "watt", + "storey" + ] + }, + "public.thermal_conductivity_unit": { + "name": "thermal_conductivity_unit", + "schema": "public", + "values": [ + "watt_per_meter_kelvin" + ] + }, + "public.goal": { + "name": "goal", + "schema": "public", + "values": [ + "Valuation Improvement", + "Increasing EPC", + "Reducing CO2 emissions", + "Energy Savings", + "None" + ] + }, + "public.portfolio_capability": { + "name": "portfolio_capability", + "schema": "public", + "values": [ + "approver", + "contractor" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "creator", + "admin", + "read", + "write" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "scoping", + "survey", + "assessment", + "tendering", + "project underway", + "completion; status: on track", + "completion; status: delayed", + "completion; status: at risk", + "completion; status: completed", + "needs review" + ] + }, + "public.override_component": { + "name": "override_component", + "schema": "public", + "values": [ + "wall_type", + "roof_type", + "property_type", + "built_form_type", + "main_fuel", + "glazing", + "construction_age_band", + "water_heating", + "main_heating_system" + ] + }, + "public.energy_element_type": { + "name": "energy_element_type", + "schema": "public", + "values": [ + "roof", + "wall", + "floor", + "main_heating", + "window", + "lighting", + "hot_water", + "secondary_heating", + "main_heating_controls" + ] + }, + "public.epc": { + "name": "epc", + "schema": "public", + "values": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G" + ] + }, + "public.creation_status": { + "name": "creation_status", + "schema": "public", + "values": [ + "LOADING", + "READY", + "ERROR" + ] + }, + "public.rebaseline_reason": { + "name": "rebaseline_reason", + "schema": "public", + "values": [ + "none", + "pre_sap10", + "physical_state_changed", + "both" + ] + }, + "public.housing_type": { + "name": "housing_type", + "schema": "public", + "values": [ + "Private", + "Social" + ] + }, + "public.measure_type": { + "name": "measure_type", + "schema": "public", + "values": [ + "air_source_heat_pump", + "boiler_upgrade", + "high_heat_retention_storage_heaters", + "secondary_heating", + "roomstat_programmer_trvs", + "time_temperature_zone_control", + "cylinder_thermostat", + "cavity_wall_insulation", + "extension_cavity_wall_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "loft_insulation", + "flat_roof_insulation", + "room_roof_insulation", + "solid_floor_insulation", + "suspended_floor_insulation", + "double_glazing", + "secondary_glazing", + "draught_proofing", + "mechanical_ventilation", + "low_energy_lighting", + "solar_pv", + "hot_water_tank_insulation", + "sealing_open_fireplace" + ] + }, + "public.plan_type": { + "name": "plan_type", + "schema": "public", + "values": [ + "solar_eco4", + "solar_hhrsh_eco4", + "empty_cavity_eco", + "partial_cavity_eco", + "extraction_eco" + ] + }, + "public.unit_quantity": { + "name": "unit_quantity", + "schema": "public", + "values": [ + "m2", + "part", + "kwp" + ] + }, + "public.scenario_type": { + "name": "scenario_type", + "schema": "public", + "values": [ + "unit", + "building" + ] + }, + "public.source": { + "name": "source", + "schema": "public", + "values": [ + "portfolio_id", + "hubspot_deal_id", + "property_id" + ] + }, + "public.file_source": { + "name": "file_source", + "schema": "public", + "values": [ + "pas hub", + "sharepoint", + "hubspot", + "ecmk", + "contractor", + "magic_plan", + "coordination_hub", + "audit_generator" + ] + }, + "public.file_type": { + "name": "file_type", + "schema": "public", + "values": [ + "photo_pack", + "site_note", + "rd_sap_site_note", + "pas_2023_ventilation", + "pas_2023_condition", + "pas_significance", + "par_photo_pack", + "pas_2023_property", + "pas_2023_occupancy", + "ecmk_site_note", + "ecmk_rd_sap_site_note", + "ecmk_survey_xml", + "pre_photo", + "mid_photo", + "post_photo", + "loft_hatch_photo", + "dmev_photos", + "door_undercut_photos", + "trickle_vent_photos", + "pre_installation_building_inspection", + "point_of_work_risk_assessment", + "claim_of_compliance", + "mcs_compliance_certificate", + "certificate_of_conformity", + "minor_works_electrical_certificate", + "trustmark_licence_numbers", + "operative_competency", + "ventilation_assessment_checklist", + "anemometer_readings", + "commissioning_records", + "part_f_ventilation_document", + "handover_pack", + "insurance_guarantee", + "workmanship_warranty", + "g98_notification", + "installer_qualifications", + "installer_feedback", + "contractor_other", + "magic_plan_json", + "improvement_option_evaluation", + "medium_term_improvement_plan", + "retrofit_design_doc", + "ventilation_audit", + "other" + ] + }, + "public.user_defined_deal_measure_source": { + "name": "user_defined_deal_measure_source", + "schema": "public", + "values": [ + "instructed", + "pibi_ordered" + ] + }, + "public.user_profiles_property_count": { + "name": "user_profiles_property_count", + "schema": "public", + "values": [ + "1", + "2–5", + "6–20", + "21+", + "1–50", + "51–100", + "101–300", + "301–1000", + "1000+" + ] + }, + "public.user_profiles_referral_source": { + "name": "user_profiles_referral_source", + "schema": "public", + "values": [ + "search", + "social_media", + "NRLA", + "partner", + "word_of_mouth", + "other" + ] + }, + "public.user_profiles_user_type": { + "name": "user_profiles_user_type", + "schema": "public", + "values": [ + "private_landlord", + "private_tenant", + "social_landlord", + "social_tenant", + "homeowner", + "other" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/app/db/migrations/meta/_journal.json b/src/app/db/migrations/meta/_journal.json index a6b36a29..cec17fa6 100644 --- a/src/app/db/migrations/meta/_journal.json +++ b/src/app/db/migrations/meta/_journal.json @@ -1842,6 +1842,13 @@ "when": 1783425021407, "tag": "0263_add_property_certificate_number", "breakpoints": true + }, + { + "idx": 264, + "version": "7", + "when": 1783430551377, + "tag": "0264_yummy_master_chief", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/db/schema/user_portfolio_config.ts b/src/app/db/schema/user_portfolio_config.ts new file mode 100644 index 00000000..2a2f969d --- /dev/null +++ b/src/app/db/schema/user_portfolio_config.ts @@ -0,0 +1,117 @@ +import { + bigserial, + bigint, + text, + integer, + timestamp, + pgTable, + unique, + index, + foreignKey, +} from "drizzle-orm/pg-core"; +import { InferModel } from "drizzle-orm"; +import { user } from "./users"; +import { portfolio } from "./portfolio"; + +// Per-user folders for organising portfolios on the home page. Folders are +// personal: two users on the same portfolio each keep their own grouping and +// ordering. Reordering renumbers position (0..n-1) in a single transaction. +export const userPortfolioFolders = pgTable( + "user_portfolio_folders", + { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + userId: bigint("user_id", { mode: "bigint" }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + name: text("name").notNull(), + position: integer("position").notNull(), + createdAt: timestamp("created_at", { + precision: 6, + withTimezone: true, + }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { + precision: 6, + withTimezone: true, + }) + .defaultNow() + .notNull(), + }, + (t) => [ + // Composite target for the same-owner FK on userPortfolioConfig. + unique("user_portfolio_folders_id_user_unique").on(t.id, t.userId), + index("idx_user_portfolio_folders_user_position").on(t.userId, t.position), + ], +); + +// One row per (user, portfolio): that user's personal configuration for the +// portfolio. Rows are created lazily on first write (upsert on the unique +// pair); no row means "all defaults". Future per-user concerns (archived, +// last_viewed_at, notification prefs) become columns here — this table is the +// user-config layer, distinct from portfolioUsers which is access control. +export const userPortfolioConfig = pgTable( + "user_portfolio_config", + { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + userId: bigint("user_id", { mode: "bigint" }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + portfolioId: bigint("portfolio_id", { mode: "bigint" }) + .notNull() + .references(() => portfolio.id, { onDelete: "cascade" }), + // Starred iff non-null; the timestamp gives "recently starred" ordering. + starredAt: timestamp("starred_at", { + precision: 6, + withTimezone: true, + }), + folderId: bigint("folder_id", { mode: "bigint" }), + createdAt: timestamp("created_at", { + precision: 6, + withTimezone: true, + }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { + precision: 6, + withTimezone: true, + }) + .defaultNow() + .notNull(), + }, + (t) => [ + unique("user_portfolio_config_user_portfolio_unique").on( + t.userId, + t.portfolioId, + ), + // A portfolio can only be filed into a folder owned by the same user: the + // FK pairs folder_id with user_id against (id, user_id) on folders. + // Deliberately NO ACTION on delete: composite FKs can't SET NULL just + // folder_id (it would null user_id too), so folder deletion must unfile + // its portfolios (SET folder_id = NULL) and delete in one transaction. A + // raw DELETE bypassing that fails loudly instead of corrupting rows. + foreignKey({ + columns: [t.folderId, t.userId], + foreignColumns: [userPortfolioFolders.id, userPortfolioFolders.userId], + name: "user_portfolio_config_folder_owner_fk", + }), + index("idx_user_portfolio_config_folder").on(t.folderId), + ], +); + +export type UserPortfolioFolder = InferModel< + typeof userPortfolioFolders, + "select" +>; +export type NewUserPortfolioFolder = InferModel< + typeof userPortfolioFolders, + "insert" +>; +export type UserPortfolioConfig = InferModel< + typeof userPortfolioConfig, + "select" +>; +export type NewUserPortfolioConfig = InferModel< + typeof userPortfolioConfig, + "insert" +>; From 31b7c28ad538befbb1fdca95d581823ac9783484 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 15:12:07 +0000 Subject: [PATCH 19/37] =?UTF-8?q?docs(design):=20home-page=20redesign=20co?= =?UTF-8?q?ntext=20=E2=80=94=20Stitch=20refs,=20PRODUCT.md,=20impeccable?= =?UTF-8?q?=20trial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design references and critique snapshot for the portfolio home redesign; impeccable design skill added to devcontainer postCreateCommand on trial. Co-Authored-By: Claude Fable 5 --- .devcontainer/devcontainer.json | 4 +- ...6-07-07T13-05-05Z__portfolios-mock-html.md | 67 + PRODUCT.md | 41 + docs/design/home-redesign/sample1.html | 792 ++++++++++++ docs/design/home-redesign/sample2.html | 972 ++++++++++++++ docs/design/home-redesign/sample3.html | 540 ++++++++ docs/design/home-redesign/sample4.html | 1136 +++++++++++++++++ 7 files changed, 3551 insertions(+), 1 deletion(-) create mode 100644 .impeccable/critique/2026-07-07T13-05-05Z__portfolios-mock-html.md create mode 100644 PRODUCT.md create mode 100644 docs/design/home-redesign/sample1.html create mode 100644 docs/design/home-redesign/sample2.html create mode 100644 docs/design/home-redesign/sample3.html create mode 100644 docs/design/home-redesign/sample4.html diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 66ffaf49..19cddff6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,9 @@ // Install Domna's curated skill set (pinned to 0.0.5) into this workspace, // then install npm deps. `gh repo clone` handles private-repo auth using // the mounted host ~/.config/gh. - "postCreateCommand": "gh repo clone Hestia-Homes/agentic-toolkit /tmp/agentic-toolkit -- --branch 0.0.8 --depth 1 && bash /tmp/agentic-toolkit/setup.sh && npm install", + // impeccable (design-guidance skill, github.com/pbakaus/impeccable) is on + // trial for the home-page redesign; drop its `skills add` step to remove it. + "postCreateCommand": "gh repo clone Hestia-Homes/agentic-toolkit /tmp/agentic-toolkit -- --branch 0.0.8 --depth 1 && bash /tmp/agentic-toolkit/setup.sh && npx --yes skills@latest add pbakaus/impeccable --agent claude-code --copy --global --yes && npm install", "forwardPorts": ["frontend:3000", "pgadmin:80"], diff --git a/.impeccable/critique/2026-07-07T13-05-05Z__portfolios-mock-html.md b/.impeccable/critique/2026-07-07T13-05-05Z__portfolios-mock-html.md new file mode 100644 index 00000000..9d2e630f --- /dev/null +++ b/.impeccable/critique/2026-07-07T13-05-05Z__portfolios-mock-html.md @@ -0,0 +1,67 @@ +--- +target: portfolios home redesign mockup (calm) +total_score: 27 +p0_count: 0 +p1_count: 3 +timestamp: 2026-07-07T13-05-05Z +slug: portfolios-mock-html +--- +# Critique: Portfolios home redesign mockup (Calm direction) + +Method: dual-agent (A: design review · B: detector/mechanical evidence). Target: scratchpad/portfolios-mock.html (artifact 76ff144a). Browser visualization skipped: no browser automation in session. + +## Design Health Score: 27/40 — Acceptable, near Good + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3 | Collapsed folders hide all state; a folded folder with 2 at-risk portfolios looks identical to a healthy one | +| 2 | Match System / Real World | 3 | Mixed metaphor: star icon + "Pin" labels + "favourite" in notes — pick one word | +| 3 | User Control and Freedom | 2 | No folder create/reorder/rename/delete anywhere; users can't control the page's primary organising structure | +| 4 | Consistency and Standards | 3 | Card hover-lift implies whole-card clickability but only the name is a link; Pinned isn't collapsible while folders are | +| 5 | Error Prevention | 3 | Low-stakes page; kebab actions undesigned so unassessable | +| 6 | Recognition Rather Than Recall | 3 | Folders only discoverable via hidden kebab menu | +| 7 | Flexibility and Efficiency | 2 | No "/" search shortcut, no bulk select/move, no collapse-all, no status sort | +| 8 | Aesthetic and Minimalist Design | 3 | Pinned-card duplication; status pill outranks the name in reading order | +| 9 | Error Recovery | 3 | No-results state names query + one-click clear (good); real page needs fetch-failure states | +| 10 | Help and Documentation | 2 | One teaching empty state (pinning); nothing teaches folders | + +## Anti-Patterns Verdict + +Passes the product slop test (design review): no banned patterns, schema-honest fields, restrained navy palette, earned familiarity. Deterministic detector: exit 0, zero findings — but its clean bill covers only its own rule set; mechanical checks beyond it found the non-text contrast failures below. + +## Priority Issues + +- **[P1] Sectioned-folders IA collapses at ~15 folders × 60 portfolios.** All folders open by default, no rollup, no jump nav → endless undifferentiated scroll; collapsing destroys at-a-glance state. Fix: collapsed-by-default with persisted open state; folder-header rollups (count, Σ properties, worst-status chips like "2 at risk"); sticky folder index above ~6 folders; or move folders to a left filter rail entirely. +- **[P1] Folder lifecycle undesigned.** No create, reorder (user's own question — order is hardcoded array), rename, delete. Fix: "New folder" affordance; folder-header kebab with Rename/Reorder/Remove; default alphabetical with per-user manual override (position column). +- **[P1] Pin interaction breaks keyboard/SR users.** innerHTML re-render destroys the focused star (focus drops to body); no aria-live announcements for pin/search-count changes. Fix in real build: targeted DOM updates or focus restoration by data-id + polite live region. +- **[P2] Non-text contrast failures (computed).** Idle star/kebab #9aa2b1 on white = 2.57:1; pinned gold star #f1bb06 on white = 1.77:1 (state nearly invisible to low-vision users); card border 1.24:1. Text pairs all pass 4.5:1 (lowest 5.59:1). +- **[P2] Search is name-only, no keyboard path.** Doesn't match status/goal/folder; no "/" focus; header count stays global while filtering (should read "Showing X of Y"). + +## Persona Red Flags + +**Alex (power user):** no keyboard route to search; only name text clickable despite whole-card hover-lift; organising 60 portfolios = 60 kebab round-trips (no bulk move); no collapse-all; no status sort; pinned duplication doubles scan work. + +**Sam (accessibility):** focus wiped on pin toggle; pinned state fails 3:1 non-text contrast; no announcements for dynamic changes; h2-in-summary risks odd NVDA announcements. Keeping: native details/summary, focus ring token, aria-pressed, reduced-motion handling. + +## Minor Observations + +- Empty folder renders a blank dashed box (empty string default) — needs teaching copy. +- Overspend (cost > budget) clamps the bar silently and stays blue — a red state would serve "trustworthy numbers". +- Status pill above the name makes colour the first read; name should lead on a dispatch page. +- Dead CSS (.updated-inline); list view <900px drops all data columns. +- "Hit the star" too casual for institutional register; unify Pinned/Starred/Favourites naming. +- Nothing signals folders are per-user, not shared with teammates. +- Mock file is a fragment (no doctype/lang/viewport) — supplied by the Artifact wrapper at publish, but the real page needs them natively; summary contains flow content (non-conforming, works in practice). +- Real page needs skeleton sections, not spinners. + +## Strengths + +- Schema-honest cards ("No budget set" shown, £-of-£ bar only when both fields exist) — Design Principle 1 executed. +- Trustworthy-numbers craft: tabular-nums, consistent £M/£k, honest >100% percentages. +- 10-value status enum compressed to 8 clean pills, text + colour, never colour alone. + +## Questions to Consider + +- What if folders were a left filter rail instead of page sections? Solves order, scale, and jump-nav in one move. +- What is a collapsed folder for, if collapsing hides all status? The header rollup is the reconciliation, not a nice-to-have. +- Is pin-as-duplicate right, or should pinning float items within their folder? diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..c69b31cc --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,41 @@ +# Product + +## Register + +product + +## Users + +Three operator groups, all professionals in a task, none casual visitors: + +- **Domna internal team** — analysts/consultants running assessments and modelling on behalf of landlords. Power users; live in the tool daily. +- **Housing-association staff** — landlord-side asset and sustainability managers tracking their portfolios' retrofit progress. Domain-expert but not tool-expert; visit weekly. +- **Contractors/approvers** — external parties with scoped capabilities on specific portfolios; arrive with a narrow job (approve, quote, deliver). + +Shared context: desk work, large screens, often alongside spreadsheets and procurement documents. The home page's job is orientation and dispatch — find the right portfolio fast and understand its state at a glance. + +## Product Purpose + +Domna assesses UK housing portfolios for energy retrofit: ingesting property data (bulk uploads, postcode search), modelling retrofit interventions, and reporting outcomes (EPC uplift, CO2/energy savings, valuation impact) against budgets. Success looks like a landlord trusting Domna's numbers enough to commit retrofit budgets against them. + +## Brand Personality + +**Hypothesis (user undecided — validate against mockup variants):** calm, expert, institutional — quiet confidence appropriate to housing-sector procurement, with measured warmth from the brand tan/brown accents. Brand navy (`#14163d`) carries identity in small, deliberate doses; the surface stays neutral and legible. Not playful, not loud, never salesy inside the product. + +## Anti-references + +- **Generic SaaS admin templates** — Bootstrap/AdminLTE dashboards, hero-metric cards, widget walls. +- **The current home page** — a bare centered grid of identical icon cards with no data, hierarchy, or organisation. +- **Over-designed AI mockup output** — 2rem-rounded cards, giant soft shadows, decorative gradients, invented data the schema can't supply. + +## Design Principles + +1. **Show real state, not decoration.** Every element on a card earns its place by reflecting actual schema data (status, goal, properties, budget). No invented metrics. +2. **Orientation before information.** The home page dispatches users to the right portfolio; scanability (favourites, folders, search, sort) beats per-card completeness. +3. **Earned familiarity.** Standard affordances done precisely — the tool should disappear into the task for someone fluent in Linear/Stripe-class products. +4. **Trustworthy numbers.** Figures are the product; typography and alignment treat numeric data with more care than any decorative element. +5. **Consistency over surprise.** One component vocabulary across the app; delight lives in moments (empty states, transitions), not pages. + +## Accessibility & Inclusion + +WCAG 2.1 AA: ≥4.5:1 body-text contrast (≥3:1 large text), full keyboard operability, visible focus states, `prefers-reduced-motion` alternatives for all animation. B2B housing-sector procurement may require conformance evidence. diff --git a/docs/design/home-redesign/sample1.html b/docs/design/home-redesign/sample1.html new file mode 100644 index 00000000..1a231709 --- /dev/null +++ b/docs/design/home-redesign/sample1.html @@ -0,0 +1,792 @@ + + + + + + + Domna | Property Portfolios + + + + + + + + + + + + + + +
+
+
+ Domna + +
+
+
+ search + +
+ + +
+ User profile +
+
+
+
+
+ +
+
+

+ Property Portfolios +

+

+ Strategic oversight of energy retrofit assets. Curate, monitor, and + scale sustainability across your entire estate architecture. +

+
+
+
+ + +
+ +
+
+ +
+
+ Status: + All Active + expand_more +
+
+ Tag: + Residential + expand_more +
+
+ Sort by: + Budget (High) + expand_more +
+
+ +
+ +
+ +
+
+
+ + In Progress +
+
+ + +
+
+

+ London Social Housing +

+

+ location_on + Greater London Authority +

+
+
+ Budget Utilization + £4.2M / £6.5M +
+
+
+
+
+
+
+ Target EPC + Rating A +
+
+ Properties + 142 Units +
+
+
+ High Priority + Residential +
+
+ +
+
+
+ + Planning +
+
+ + +
+
+

+ Commercial Retrofit 2026 +

+

+ location_on + Manchester City Council +

+
+
+ Budget Utilization + £0.8M / £12.0M +
+
+
+
+
+
+
+ Target EPC + Rating B+ +
+
+ Properties + 28 Assets +
+
+
+ Commercial + Carbon Neutral +
+
+ +
+
+
+ + In Progress +
+
+ + +
+
+

+ Oxfordshire Eco Village +

+

+ location_on + Regional Development +

+
+
+ Budget Utilization + £2.9M / £3.1M +
+
+
+
+
+
+
+ Target EPC + Net Zero +
+
+ Properties + 18 Units +
+
+
+ Sustainability +
+
+ +
+
+
+ + In Progress +
+
+ + +
+
+

+ Northern Estate Phase II +

+

+ location_on + Leeds District +

+
+
+ Budget Utilization + £5.1M / £15.0M +
+
+
+
+
+
+
+ Target EPC + Rating B +
+
+ Properties + 310 Units +
+
+
+ Residential + Scale +
+
+ +
+
+
+ + Planning +
+
+ + +
+
+

+ Birmingham Tech Hub +

+

+ location_on + Innovation District +

+
+
+ Budget Utilization + £0.1M / £4.5M +
+
+
+
+
+
+
+ Target EPC + Rating A +
+
+ Properties + 4 Assets +
+
+
+ Commercial +
+
+ + +
+ +
+
+ + + + + ... + + +
+

+ Showing 6 of 142 portfolios +

+
+
+ + + + diff --git a/docs/design/home-redesign/sample2.html b/docs/design/home-redesign/sample2.html new file mode 100644 index 00000000..6cd777c0 --- /dev/null +++ b/docs/design/home-redesign/sample2.html @@ -0,0 +1,972 @@ + + + + ```html + + + + Domna | Property Portfolios + + + + + + + + + + +
+
+
+ Domna + +
+
+
+ search + +
+ + +
+ User profile +
+
+
+
+
+
+ +
+
+

+ Property Portfolios +

+

+ Strategic oversight of energy retrofit assets. Curate, monitor, + and scale sustainability across your entire estate architecture. +

+
+
+
+ Show + +
+
+ + +
+
+
+
+
+
+ Status: + All Active + expand_more +
+
+ Tag: + Residential + expand_more +
+
+ Sort by: + Budget (High) + expand_more +
+
+ +
+
+
+
+
+ + In Progress +
+
+ + +
+
+

+ London Social Housing +

+

+ location_on + Greater London Authority +

+
+
+ Budget Utilization + £4.2M / £6.5M +
+
+
+
+
+
+
+ Target EPC + Rating A +
+
+ Properties + 142 Units +
+
+
+ High Priority + Residential +
+
+
+
+
+ + Planning +
+
+ + +
+
+

+ Commercial Retrofit 2026 +

+

+ location_on + Manchester City Council +

+
+
+ Budget Utilization + £0.8M / £12.0M +
+
+
+
+
+
+
+ Target EPC + Rating B+ +
+
+ Properties + 28 Assets +
+
+
+ Commercial + Carbon Neutral +
+
+
+
+
+ + In Progress +
+
+ + +
+
+

+ Oxfordshire Eco Village +

+

+ location_on + Regional Development +

+
+
+ Budget Utilization + £2.9M / £3.1M +
+
+
+
+
+
+
+ Target EPC + Net Zero +
+
+ Properties + 18 Units +
+
+
+ Sustainability +
+
+
+
+
+ + In Progress +
+
+ + +
+
+

+ Northern Estate Phase II +

+

+ location_on + Leeds District +

+
+
+ Budget Utilization + £5.1M / £15.0M +
+
+
+
+
+
+
+ Target EPC + Rating B +
+
+ Properties + 310 Units +
+
+
+ Residential + Scale +
+
+
+
+
+ + Planning +
+
+ + +
+
+

+ Birmingham Tech Hub +

+

+ location_on + Innovation District +

+
+
+ Budget Utilization + £0.1M / £4.5M +
+
+
+
+
+
+
+ Target EPC + Rating A +
+
+ Properties + 4 Assets +
+
+
+ Commercial +
+
+ +
+ +
+
+ + + + + ... + + +
+

+ Showing 6 of 142 portfolios +

+
+
+ + + ``` + + diff --git a/docs/design/home-redesign/sample3.html b/docs/design/home-redesign/sample3.html new file mode 100644 index 00000000..643356e3 --- /dev/null +++ b/docs/design/home-redesign/sample3.html @@ -0,0 +1,540 @@ + + + + + + Domna | Property Portfolios + + + + + + + + + +
+
+
+ Domna + +
+
+
+ search + +
+ + +
+ User profile +
+
+
+
+
+
+ +
+
+
+

+ Property Portfolios +

+

+ Strategic oversight of energy retrofit assets. Curate, monitor, and + scale sustainability across your entire estate architecture. +

+
+
+
+ + +
+
+
+
+
+ Status: + All Active + expand_more +
+
+ Tag: + Residential + expand_more +
+
+ Sort by: + Budget (High) + expand_more +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Portfolio NameStatusBudget ProgressTarget EPCProperties
+
+
+ London Social Housing +
+
+ location_on + Greater London Authority +
+
+
+
+ + In Progress +
+
+
+
+ 64% + £4.2M / £6.5M +
+
+
+
+
+
+ Rating A + + 142 Units + + +
+
+
+ Commercial Retrofit 2026 +
+
+ location_on + Manchester City Council +
+
+
+
+ + Planning +
+
+
+
+ 7% + £0.8M / £12.0M +
+
+
+
+
+
+ Rating B+ + + 28 Assets + + +
+
+
+ Oxfordshire Eco Village +
+
+ location_on + Regional Development +
+
+
+
+ + In Progress +
+
+
+
+ 93% + £2.9M / £3.1M +
+
+
+
+
+
+ Net Zero + + 18 Units + + +
+
+
+
+ Items per page: +
+ + expand_more +
+
+
+
+ + + + + ... + + +
+

+ Showing 1-25 of 142 portfolios +

+
+ +
+
+ + + diff --git a/docs/design/home-redesign/sample4.html b/docs/design/home-redesign/sample4.html new file mode 100644 index 00000000..ed6dde8e --- /dev/null +++ b/docs/design/home-redesign/sample4.html @@ -0,0 +1,1136 @@ + + + + + + Domna | Property Portfolios + + + + + + + + + + + +
+
+
+ Domna + +
+
+
+ search + +
+ + +
+ User profile +
+
+
+
+
+
+
+ + +
+

+ Property Portfolios +

+
+
+
+ + +
+
+
+
+
+ Status: + All Active + expand_more +
+
+ Tag: + Residential + expand_more +
+
+ Sort by: + Budget (High) + expand_more +
+ +
+
+
+ Show: + + unfold_more +
+
+
+
+
+
+
+ + In Progress +
+
+ + +
+
+

+ London Social Housing +

+

+ location_onGreater London Authority +

+
+
+ Budget Utilization + £4.2M / £6.5M +
+
+
+
+
+
+
+ Target EPCRating A +
+
+ Properties142 Units +
+
+
+ High Priority + Residential +
+
+
+
+
+ + Planning +
+
+ + +
+
+

+ Commercial Retrofit 2026 +

+

+ location_onManchester City Council +

+
+
+ Budget Utilization + £0.8M / £12.0M +
+
+
+
+
+
+
+ Target EPCRating B+ +
+
+ Properties28 Assets +
+
+
+ Commercial + Carbon Neutral +
+
+
+
+
+ + In Progress +
+
+ + +
+
+

+ Oxfordshire Eco Village +

+

+ location_onRegional Development +

+
+
+ Budget Utilization + £2.9M / £3.1M +
+
+
+
+
+
+
+ Target EPCNet Zero +
+
+ Properties18 Units +
+
+
+ Sustainability +
+
+
+
+
+ + In Progress +
+
+ + +
+
+

+ Northern Estate Phase II +

+

+ location_onLeeds District +

+
+
+ Budget Utilization + £5.1M / £15.0M +
+
+
+
+
+
+
+ Target EPCRating B +
+
+ Properties310 Units +
+
+
+ Residential +
+
+
+
+
+ + Planning +
+
+ + +
+
+

+ Birmingham Tech Hub +

+

+ location_onInnovation District +

+
+
+ Budget Utilization + £0.1M / £4.5M +
+
+
+
+
+
+
+ Target EPCRating A +
+
+ Properties4 Assets +
+
+
+ Commercial +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Portfolio Name + + Status + + Location + + EPC Target + + Budget Progress + + Assets +
+
+ star + London Social Housing +
+
+ In Progress + + Greater London Authority + + A + +
+
+
+
+ 64% +
+
142 + +
+
+ star + Commercial Retrofit 2026 +
+
+ Planning + + Manchester City Council + + B+ + +
+
+
+
+ 7% +
+
28 + +
+
+ star + Oxfordshire Eco Village +
+
+ In Progress + + Regional Development + + Net Zero + +
+
+
+
+ 93% +
+
18 + +
+
+ star + Northern Estate Phase II +
+
+ In Progress + + Leeds District + + B + +
+
+
+
+ 34% +
+
310 + +
+
+ star + Birmingham Tech Hub +
+
+ Planning + + Innovation District + + A + +
+
+
+
+ 2% +
+
4 + +
+
+
+
+
+ + + + + ... + + +
+

+ Showing 6 of + 142 portfolios +

+
+
+ + + From 633c12762020a7ef1be38893a065656e66b0abeb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 15:12:07 +0000 Subject: [PATCH 20/37] feat(home): per-user portfolio config planning layer + /api/me routes TDD'd pure planning core (planFolderReorder with payload validation, groupPortfoliosForHome with starred float + recency, planFolderDeletion encoding unfile-then-delete, buildStarChange owning starred_at-as-flag), applied by thin route handlers: folder create/rename/delete/reorder, per-portfolio config upsert, and the grouped home read. Co-Authored-By: Claude Fable 5 --- src/app/api/me/folders/[folderId]/route.ts | 116 ++++++++++++++++++ src/app/api/me/folders/reorder/route.ts | 62 ++++++++++ src/app/api/me/folders/route.ts | 53 ++++++++ src/app/api/me/portfolio-home/route.ts | 65 ++++++++++ .../portfolios/[portfolioId]/config/route.ts | 89 ++++++++++++++ src/app/lib/portfolioUserConfig.test.ts | 114 +++++++++++++++++ src/app/lib/portfolioUserConfig.ts | 109 ++++++++++++++++ 7 files changed, 608 insertions(+) create mode 100644 src/app/api/me/folders/[folderId]/route.ts create mode 100644 src/app/api/me/folders/reorder/route.ts create mode 100644 src/app/api/me/folders/route.ts create mode 100644 src/app/api/me/portfolio-home/route.ts create mode 100644 src/app/api/me/portfolios/[portfolioId]/config/route.ts create mode 100644 src/app/lib/portfolioUserConfig.test.ts create mode 100644 src/app/lib/portfolioUserConfig.ts diff --git a/src/app/api/me/folders/[folderId]/route.ts b/src/app/api/me/folders/[folderId]/route.ts new file mode 100644 index 00000000..d00a3bfd --- /dev/null +++ b/src/app/api/me/folders/[folderId]/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { and, eq, inArray } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { + userPortfolioConfig, + userPortfolioFolders, +} from "@/app/db/schema/user_portfolio_config"; +import { planFolderDeletion } from "@/app/lib/portfolioUserConfig"; + +const RenameSchema = z.object({ + name: z.string().trim().min(1).max(120), +}); + +const FolderIdSchema = z.string().regex(/^\d+$/); + +export async function PATCH( + req: NextRequest, + props: { params: Promise<{ folderId: string }> }, +) { + try { + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Unauthenticated" }, { status: 401 }); + } + const userId = session.user.dbId; + + const { folderId } = await props.params; + if (!FolderIdSchema.safeParse(folderId).success) { + return NextResponse.json({ error: "Folder not found" }, { status: 404 }); + } + + const parsed = RenameSchema.safeParse(await req.json()); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid folder name" }, { status: 400 }); + } + + const renamed = await db + .update(userPortfolioFolders) + .set({ name: parsed.data.name, updatedAt: new Date() }) + .where( + and( + eq(userPortfolioFolders.id, BigInt(folderId)), + eq(userPortfolioFolders.userId, userId), + ), + ) + .returning({ id: userPortfolioFolders.id }); + + if (renamed.length === 0) { + return NextResponse.json({ error: "Folder not found" }, { status: 404 }); + } + return NextResponse.json({ ok: true }); + } catch (err) { + console.error("Error renaming folder:", err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} + +// Folder deletion never deletes portfolios — it unfiles them. The same-owner +// FK is NO ACTION, so unfile + delete must happen in one transaction, +// unfile first (see planFolderDeletion). +export async function DELETE( + _req: NextRequest, + props: { params: Promise<{ folderId: string }> }, +) { + try { + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Unauthenticated" }, { status: 401 }); + } + const userId = session.user.dbId; + + const { folderId } = await props.params; + if (!FolderIdSchema.safeParse(folderId).success) { + return NextResponse.json({ error: "Folder not found" }, { status: 404 }); + } + + const configs = await db + .select({ + id: userPortfolioConfig.id, + folderId: userPortfolioConfig.folderId, + }) + .from(userPortfolioConfig) + .where(eq(userPortfolioConfig.userId, userId)); + + const plan = planFolderDeletion({ folderId: BigInt(folderId), configs }); + + const deleted = await db.transaction(async (tx) => { + if (plan.unfileConfigIds.length > 0) { + await tx + .update(userPortfolioConfig) + .set({ folderId: null, updatedAt: new Date() }) + .where(inArray(userPortfolioConfig.id, plan.unfileConfigIds)); + } + return tx + .delete(userPortfolioFolders) + .where( + and( + eq(userPortfolioFolders.id, plan.deleteFolderId), + eq(userPortfolioFolders.userId, userId), + ), + ) + .returning({ id: userPortfolioFolders.id }); + }); + + if (deleted.length === 0) { + return NextResponse.json({ error: "Folder not found" }, { status: 404 }); + } + return NextResponse.json({ ok: true }); + } catch (err) { + console.error("Error deleting folder:", err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/me/folders/reorder/route.ts b/src/app/api/me/folders/reorder/route.ts new file mode 100644 index 00000000..3addbd8e --- /dev/null +++ b/src/app/api/me/folders/reorder/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { and, eq } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { userPortfolioFolders } from "@/app/db/schema/user_portfolio_config"; +import { planFolderReorder } from "@/app/lib/portfolioUserConfig"; + +const ReorderSchema = z.object({ + orderedIds: z.array(z.string().regex(/^\d+$/)), +}); + +// Drag-and-drop reorder: the client sends the FULL ordered folder id list in +// one request; positions are renumbered in a single transaction so the write +// is idempotent and can't drift. +export async function PATCH(req: NextRequest) { + try { + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Unauthenticated" }, { status: 401 }); + } + const userId = session.user.dbId; + + const parsed = ReorderSchema.safeParse(await req.json()); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid folder order" }, { status: 400 }); + } + + const current = await db + .select({ id: userPortfolioFolders.id }) + .from(userPortfolioFolders) + .where(eq(userPortfolioFolders.userId, userId)); + + const plan = planFolderReorder({ + currentIds: current.map((f) => f.id), + orderedIds: parsed.data.orderedIds.map(BigInt), + }); + if ("error" in plan) { + return NextResponse.json({ error: plan.error }, { status: 400 }); + } + + await db.transaction(async (tx) => { + for (const { id, position } of plan.updates) { + await tx + .update(userPortfolioFolders) + .set({ position, updatedAt: new Date() }) + .where( + and( + eq(userPortfolioFolders.id, id), + eq(userPortfolioFolders.userId, userId), + ), + ); + } + }); + + return NextResponse.json({ ok: true }); + } catch (err) { + console.error("Error reordering folders:", err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/me/folders/route.ts b/src/app/api/me/folders/route.ts new file mode 100644 index 00000000..1479f90b --- /dev/null +++ b/src/app/api/me/folders/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { eq, sql } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { userPortfolioFolders } from "@/app/db/schema/user_portfolio_config"; + +const CreateFolderSchema = z.object({ + name: z.string().trim().min(1).max(120), +}); + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Unauthenticated" }, { status: 401 }); + } + const userId = session.user.dbId; + + const parsed = CreateFolderSchema.safeParse(await req.json()); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid folder name" }, { status: 400 }); + } + + // New folders go last: position = current folder count. + const [created] = await db + .insert(userPortfolioFolders) + .values({ + userId, + name: parsed.data.name, + position: sql`(select count(*) from ${userPortfolioFolders} where ${eq( + userPortfolioFolders.userId, + userId, + )})`, + }) + .returning(); + + return NextResponse.json( + { + folder: { + id: created.id.toString(), + name: created.name, + position: created.position, + }, + }, + { status: 201 }, + ); + } catch (err) { + console.error("Error creating folder:", err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/me/portfolio-home/route.ts b/src/app/api/me/portfolio-home/route.ts new file mode 100644 index 00000000..4fb62a7c --- /dev/null +++ b/src/app/api/me/portfolio-home/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { getPortfolios } from "@/app/home/utils"; +import { + userPortfolioConfig, + userPortfolioFolders, +} from "@/app/db/schema/user_portfolio_config"; +import { groupPortfoliosForHome } from "@/app/lib/portfolioUserConfig"; + +// Everything the home page renders: the caller's portfolios grouped by their +// personal folders, with starred float and a most-recently-starred list. +export async function GET() { + try { + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Unauthenticated" }, { status: 401 }); + } + const userId = session.user.dbId; + + const [portfolios, configs, folders] = await Promise.all([ + getPortfolios(userId), + db + .select({ + portfolioId: userPortfolioConfig.portfolioId, + folderId: userPortfolioConfig.folderId, + starredAt: userPortfolioConfig.starredAt, + }) + .from(userPortfolioConfig) + .where(eq(userPortfolioConfig.userId, userId)), + db + .select({ + id: userPortfolioFolders.id, + name: userPortfolioFolders.name, + position: userPortfolioFolders.position, + }) + .from(userPortfolioFolders) + .where(eq(userPortfolioFolders.userId, userId)), + ]); + + const starredIds = new Set( + configs.filter((c) => c.starredAt !== null).map((c) => c.portfolioId), + ); + const grouped = groupPortfoliosForHome({ portfolios, configs, folders }); + const body = { + folders: grouped.folders, + unfiled: grouped.unfiled, + starred: grouped.starred, + starredIds: [...starredIds], + }; + + // bigint isn't JSON-serialisable; ids cross the wire as strings. + return new NextResponse( + JSON.stringify(body, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + ), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } catch (err) { + console.error("Error loading portfolio home:", err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/me/portfolios/[portfolioId]/config/route.ts b/src/app/api/me/portfolios/[portfolioId]/config/route.ts new file mode 100644 index 00000000..7ec6adde --- /dev/null +++ b/src/app/api/me/portfolios/[portfolioId]/config/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { and, eq } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { userPortfolioConfig } from "@/app/db/schema/user_portfolio_config"; +import { buildStarChange } from "@/app/lib/portfolioUserConfig"; + +const ConfigSchema = z + .object({ + starred: z.boolean().optional(), + folderId: z.string().regex(/^\d+$/).nullable().optional(), + }) + .refine((v) => v.starred !== undefined || v.folderId !== undefined, { + message: "Nothing to update", + }); + +// Upserts the caller's personal config for one portfolio (star / move to +// folder). Rows are created lazily: no row means all defaults. +export async function PATCH( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + try { + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Unauthenticated" }, { status: 401 }); + } + const userId = session.user.dbId; + + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Portfolio not found" }, { status: 404 }); + } + + const parsed = ConfigSchema.safeParse(await req.json()); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid config" }, { status: 400 }); + } + + // Config only exists for portfolios the user can actually see. + const membership = await db + .select({ id: portfolioUsers.id }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.userId, userId), + eq(portfolioUsers.portfolioId, BigInt(portfolioId)), + ), + ); + if (membership.length === 0) { + return NextResponse.json({ error: "Portfolio not found" }, { status: 404 }); + } + + const change: Partial<{ + starredAt: Date | null; + folderId: bigint | null; + }> = {}; + if (parsed.data.starred !== undefined) { + change.starredAt = buildStarChange({ + starred: parsed.data.starred, + now: new Date(), + }).starredAt; + } + if (parsed.data.folderId !== undefined) { + change.folderId = + parsed.data.folderId === null ? null : BigInt(parsed.data.folderId); + } + + await db + .insert(userPortfolioConfig) + .values({ + userId, + portfolioId: BigInt(portfolioId), + ...change, + }) + .onConflictDoUpdate({ + target: [userPortfolioConfig.userId, userPortfolioConfig.portfolioId], + set: { ...change, updatedAt: new Date() }, + }); + + return NextResponse.json({ ok: true }); + } catch (err) { + console.error("Error updating portfolio config:", err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/lib/portfolioUserConfig.test.ts b/src/app/lib/portfolioUserConfig.test.ts new file mode 100644 index 00000000..9ed087b5 --- /dev/null +++ b/src/app/lib/portfolioUserConfig.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + buildStarChange, + groupPortfoliosForHome, + planFolderDeletion, + planFolderReorder, +} from "./portfolioUserConfig"; + +describe("planFolderReorder", () => { + it("renumbers every folder to its index in the requested order", () => { + const plan = planFolderReorder({ + currentIds: [1n, 2n, 3n], + orderedIds: [3n, 1n, 2n], + }); + + expect(plan).toEqual({ + updates: [ + { id: 3n, position: 0 }, + { id: 1n, position: 1 }, + { id: 2n, position: 2 }, + ], + }); + }); + + it("rejects an order naming a folder the user does not have", () => { + const plan = planFolderReorder({ + currentIds: [1n, 2n], + orderedIds: [2n, 99n], + }); + + expect(plan).toEqual({ + error: "Folder order does not match your folders", + }); + }); + + it("rejects an order that lists the same folder twice", () => { + const plan = planFolderReorder({ + currentIds: [1n, 2n], + orderedIds: [1n, 1n, 2n], + }); + + expect(plan).toEqual({ + error: "Folder order does not match your folders", + }); + }); +}); + +describe("groupPortfoliosForHome", () => { + it("groups portfolios into folders ordered by position, with the rest unfiled", () => { + const grouped = groupPortfoliosForHome({ + portfolios: [{ id: 1n }, { id: 2n }, { id: 3n }], + configs: [ + { portfolioId: 1n, folderId: 10n, starredAt: null }, + { portfolioId: 2n, folderId: 11n, starredAt: null }, + ], + folders: [ + { id: 10n, name: "SHDF Wave 3", position: 1 }, + { id: 11n, name: "Demos", position: 0 }, + ], + }); + + expect(grouped.folders).toEqual([ + { id: 11n, name: "Demos", portfolios: [{ id: 2n }] }, + { id: 10n, name: "SHDF Wave 3", portfolios: [{ id: 1n }] }, + ]); + expect(grouped.unfiled).toEqual([{ id: 3n }]); + }); + + it("floats starred portfolios to the top of their group and lists them most-recently-starred first", () => { + const grouped = groupPortfoliosForHome({ + portfolios: [{ id: 1n }, { id: 2n }, { id: 3n }, { id: 4n }], + configs: [ + { portfolioId: 1n, folderId: 10n, starredAt: null }, + { portfolioId: 2n, folderId: 10n, starredAt: new Date("2026-07-01") }, + { portfolioId: 4n, folderId: null, starredAt: new Date("2026-07-05") }, + ], + folders: [{ id: 10n, name: "SHDF Wave 3", position: 0 }], + }); + + expect(grouped.folders[0].portfolios).toEqual([{ id: 2n }, { id: 1n }]); + expect(grouped.unfiled).toEqual([{ id: 4n }, { id: 3n }]); + expect(grouped.starred).toEqual([{ id: 4n }, { id: 2n }]); + }); +}); + +describe("planFolderDeletion", () => { + it("unfiles exactly the configs in the folder, then deletes the folder", () => { + const plan = planFolderDeletion({ + folderId: 10n, + configs: [ + { id: 1n, folderId: 10n }, + { id: 2n, folderId: 11n }, + { id: 3n, folderId: 10n }, + { id: 4n, folderId: null }, + ], + }); + + expect(plan).toEqual({ + unfileConfigIds: [1n, 3n], + deleteFolderId: 10n, + }); + }); +}); + +describe("buildStarChange", () => { + it("starring stamps the moment, unstarring clears it", () => { + const now = new Date("2026-07-07T12:00:00Z"); + + expect(buildStarChange({ starred: true, now })).toEqual({ starredAt: now }); + expect(buildStarChange({ starred: false, now })).toEqual({ + starredAt: null, + }); + }); +}); diff --git a/src/app/lib/portfolioUserConfig.ts b/src/app/lib/portfolioUserConfig.ts new file mode 100644 index 00000000..37203e64 --- /dev/null +++ b/src/app/lib/portfolioUserConfig.ts @@ -0,0 +1,109 @@ +// Planning functions for the per-user portfolio workspace (starred portfolios +// and personal folders — user_portfolio_config / user_portfolio_folders). +// Pure data-in/plan-out: route handlers and server components apply the plans +// with drizzle. See docs/design/home-redesign/ for the interaction design. + +export type FolderReorderPlan = + | { updates: Array<{ id: bigint; position: number }> } + | { error: string }; + +// The drag-drop reorder PATCH sends the full ordered id list; the plan +// renumbers every folder to its index so positions stay dense (0..n-1). +export function planFolderReorder({ + currentIds, + orderedIds, +}: { + currentIds: bigint[]; + orderedIds: bigint[]; +}): FolderReorderPlan { + const current = new Set(currentIds); + const requested = new Set(orderedIds); + const sameSet = + orderedIds.length === currentIds.length && + current.size === requested.size && + [...requested].every((id) => current.has(id)); + if (!sameSet) { + return { error: "Folder order does not match your folders" }; + } + return { + updates: orderedIds.map((id, index) => ({ id, position: index })), + }; +} + +// starred_at doubles as the flag (non-null = starred) and the "recently +// starred" sort key; this is the one place that convention is written down. +export function buildStarChange({ + starred, + now, +}: { + starred: boolean; + now: Date; +}): { starredAt: Date | null } { + return { starredAt: starred ? now : null }; +} + +// The same-owner FK on user_portfolio_config is NO ACTION, so a folder can +// only be deleted after its configs are unfiled — both steps must run in one +// transaction, unfile first. This plan makes that ordering explicit. +export function planFolderDeletion({ + folderId, + configs, +}: { + folderId: bigint; + configs: Array<{ id: bigint; folderId: bigint | null }>; +}): { unfileConfigIds: bigint[]; deleteFolderId: bigint } { + return { + unfileConfigIds: configs + .filter((c) => c.folderId === folderId) + .map((c) => c.id), + deleteFolderId: folderId, + }; +} + +type ConfigRow = { + portfolioId: bigint; + folderId: bigint | null; + starredAt: Date | null; +}; + +type FolderRow = { id: bigint; name: string; position: number }; + +// Assembles the home page's render model from the three per-user reads. +// Generic over the portfolio payload so it doesn't care which columns the +// page selects. +export function groupPortfoliosForHome

({ + portfolios, + configs, + folders, +}: { + portfolios: P[]; + configs: ConfigRow[]; + folders: FolderRow[]; +}): { + folders: Array<{ id: bigint; name: string; portfolios: P[] }>; + unfiled: P[]; + starred: P[]; +} { + const configByPortfolio = new Map(configs.map((c) => [c.portfolioId, c])); + const folderOf = (p: P) => configByPortfolio.get(p.id)?.folderId ?? null; + const starredAt = (p: P) => configByPortfolio.get(p.id)?.starredAt ?? null; + + // Starred float above unstarred; original relative order is otherwise kept. + const floatStarred = (group: P[]) => + [...group].sort( + (a, b) => Number(starredAt(b) !== null) - Number(starredAt(a) !== null), + ); + + const orderedFolders = [...folders].sort((a, b) => a.position - b.position); + return { + folders: orderedFolders.map((f) => ({ + id: f.id, + name: f.name, + portfolios: floatStarred(portfolios.filter((p) => folderOf(p) === f.id)), + })), + unfiled: floatStarred(portfolios.filter((p) => folderOf(p) === null)), + starred: portfolios + .filter((p) => starredAt(p) !== null) + .sort((a, b) => starredAt(b)!.getTime() - starredAt(a)!.getTime()), + }; +} From e0d2b9dbefc935516f250fdb925ff9ddb3ebeb81 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 15:46:58 +0000 Subject: [PATCH 21/37] feat(bulk-upload): add uprn corrections table for pre-finalise confirmation (ADR-0057) Foundation for the "Addresses" review tab: captures the user's confirmed UPRN per combiner row, keyed by source_row_id (no property.id exists pre-finalise). At dispatchFinaliser these are overlaid onto the combiner CSV so the finaliser builds identity + property_overrides from the confirmed UPRN in one pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../db/migrations/0261_uprn_corrections.sql | 15 + src/app/db/migrations/meta/0261_snapshot.json | 11882 ++++++++++++++++ src/app/db/migrations/meta/_journal.json | 7 + .../db/schema/bulk_upload_uprn_corrections.ts | 46 + 4 files changed, 11950 insertions(+) create mode 100644 src/app/db/migrations/0261_uprn_corrections.sql create mode 100644 src/app/db/migrations/meta/0261_snapshot.json create mode 100644 src/app/db/schema/bulk_upload_uprn_corrections.ts diff --git a/src/app/db/migrations/0261_uprn_corrections.sql b/src/app/db/migrations/0261_uprn_corrections.sql new file mode 100644 index 00000000..b81c825d --- /dev/null +++ b/src/app/db/migrations/0261_uprn_corrections.sql @@ -0,0 +1,15 @@ +CREATE TABLE "bulk_upload_uprn_corrections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "upload_id" uuid NOT NULL, + "source_row_id" text NOT NULL, + "uprn" text, + "address" text, + "postcode" text, + "marked_no_uprn" boolean DEFAULT false NOT NULL, + "user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "bulk_upload_uprn_corrections_upload_row_unique" UNIQUE("upload_id","source_row_id") +); +--> statement-breakpoint +ALTER TABLE "bulk_upload_uprn_corrections" ADD CONSTRAINT "bulk_upload_uprn_corrections_upload_id_bulk_address_uploads_id_fk" FOREIGN KEY ("upload_id") REFERENCES "public"."bulk_address_uploads"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/src/app/db/migrations/meta/0261_snapshot.json b/src/app/db/migrations/meta/0261_snapshot.json new file mode 100644 index 00000000..84f48943 --- /dev/null +++ b/src/app/db/migrations/meta/0261_snapshot.json @@ -0,0 +1,11882 @@ +{ + "id": "d6ffb58e-4e75-4c2d-8375-e6bad4d308e0", + "prevId": "9e25eb5b-9f92-4266-a437-80668bafd01c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.postcode_search": { + "name": "postcode_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_data": { + "name": "result_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_updated_at": { + "name": "last_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "postcode_search_postcode_unique": { + "name": "postcode_search_postcode_unique", + "nullsNotDistinct": false, + "columns": [ + "postcode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deal_measure_approval_events": { + "name": "deal_measure_approval_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_by": { + "name": "acted_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deal_measure_events_deal_id": { + "name": "idx_deal_measure_events_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deal_measure_events_acted_at": { + "name": "idx_deal_measure_events_acted_at", + "columns": [ + { + "expression": "acted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deal_measure_approval_events_acted_by_user_id_fk": { + "name": "deal_measure_approval_events_acted_by_user_id_fk", + "tableFrom": "deal_measure_approval_events", + "tableTo": "user", + "columnsFrom": [ + "acted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deal_measure_approvals": { + "name": "deal_measure_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_approved": { + "name": "is_approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "approved_by": { + "name": "approved_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deal_measure_approvals_deal_id": { + "name": "idx_deal_measure_approvals_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deal_measure_approvals_approved_by_user_id_fk": { + "name": "deal_measure_approvals_approved_by_user_id_fk", + "tableFrom": "deal_measure_approvals", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_deal_measure": { + "name": "uq_deal_measure", + "nullsNotDistinct": false, + "columns": [ + "hubspot_deal_id", + "measure_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bulk_address_uploads": { + "name": "bulk_address_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_bucket": { + "name": "s3_bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready_for_processing'" + }, + "source_headers": { + "name": "source_headers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "column_mapping": { + "name": "column_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "multi_entry_summary": { + "name": "multi_entry_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "multi_entry_ordering": { + "name": "multi_entry_ordering", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verify_ack": { + "name": "verify_ack", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "combined_output_s3_uri": { + "name": "combined_output_s3_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bulk_upload_uprn_corrections": { + "name": "bulk_upload_uprn_corrections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_row_id": { + "name": "source_row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "marked_no_uprn": { + "name": "marked_no_uprn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bulk_upload_uprn_corrections_upload_id_bulk_address_uploads_id_fk": { + "name": "bulk_upload_uprn_corrections_upload_id_bulk_address_uploads_id_fk", + "tableFrom": "bulk_upload_uprn_corrections", + "tableTo": "bulk_address_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bulk_upload_uprn_corrections_upload_row_unique": { + "name": "bulk_upload_uprn_corrections_upload_row_unique", + "nullsNotDistinct": false, + "columns": [ + "upload_id", + "source_row_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.aspect_condition": { + "name": "aspect_condition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "element_id": { + "name": "element_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "aspect_type": { + "name": "aspect_type", + "type": "aspect_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "aspect_instance": { + "name": "aspect_instance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "install_date": { + "name": "install_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "renewal_year": { + "name": "renewal_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "comments": { + "name": "comments", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "aspect_condition_element_id_element_id_fk": { + "name": "aspect_condition_element_id_element_id_fk", + "tableFrom": "aspect_condition", + "tableTo": "element", + "columnsFrom": [ + "element_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.element": { + "name": "element", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "survey_id": { + "name": "survey_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "element_type": { + "name": "element_type", + "type": "element_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "element_instance": { + "name": "element_instance", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "element_survey_id_property_condition_survey_id_fk": { + "name": "element_survey_id_property_condition_survey_id_fk", + "tableFrom": "element", + "tableTo": "property_condition_survey", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_condition_survey": { + "name": "property_condition_survey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hubspot_company_data": { + "name": "hubspot_company_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hubspot_deal_data": { + "name": "hubspot_deal_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deal_id": { + "name": "deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dealname": { + "name": "dealname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dealstage": { + "name": "dealstage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_code": { + "name": "project_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landlord_property_id": { + "name": "landlord_property_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_id": { + "name": "listing_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uprn": { + "name": "uprn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome_notes": { + "name": "outcome_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "major_condition_issue_description": { + "name": "major_condition_issue_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "major_condition_issue_photos": { + "name": "major_condition_issue_photos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "major_condition_issue_evidence_s3_url": { + "name": "major_condition_issue_evidence_s3_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coordination_status": { + "name": "coordination_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "design_status": { + "name": "design_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "booking_status": { + "name": "booking_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pashub_link": { + "name": "pashub_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sharepoint_link": { + "name": "sharepoint_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dampmould_growth": { + "name": "dampmould_growth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pre_sap": { + "name": "pre_sap", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coordinator": { + "name": "coordinator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mtp_completion_date": { + "name": "mtp_completion_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "mtp_re_model_completion_date": { + "name": "mtp_re_model_completion_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "ioe_v3_completion_date": { + "name": "ioe_v3_completion_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "proposed_measures": { + "name": "proposed_measures", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_package": { + "name": "approved_package", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "designer": { + "name": "designer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "design_type": { + "name": "design_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "design_completion_date": { + "name": "design_completion_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "actual_measures_installed": { + "name": "actual_measures_installed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installer": { + "name": "installer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installer_handover": { + "name": "installer_handover", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lodgement_status": { + "name": "lodgement_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "measures_lodgement_date": { + "name": "measures_lodgement_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "lodgement_date": { + "name": "lodgement_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "expected_commencement_date": { + "name": "expected_commencement_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "coordination_comments": { + "name": "coordination_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surveyor": { + "name": "surveyor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "damp_mould_and_repairs_comments": { + "name": "damp_mould_and_repairs_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch": { + "name": "batch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch_description": { + "name": "batch_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_reference": { + "name": "block_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonfunded_measures": { + "name": "nonfunded_measures", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_prn": { + "name": "epc_prn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "potential_post_sap_score_dropdown": { + "name": "potential_post_sap_score_dropdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ei_score": { + "name": "ei_score", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ei_score__potential_": { + "name": "ei_score__potential_", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_sap_score": { + "name": "epc_sap_score", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_sap_score__potential_": { + "name": "epc_sap_score__potential_", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmed_survey_date": { + "name": "confirmed_survey_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmed_survey_time": { + "name": "confirmed_survey_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surveyed_date": { + "name": "surveyed_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "survey_type": { + "name": "survey_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "measures_for_pibi_ordered": { + "name": "measures_for_pibi_ordered", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pibi_order_date": { + "name": "pibi_order_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "pibi_completed_date": { + "name": "pibi_completed_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "property_halted_date": { + "name": "property_halted_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "property_halted_reason": { + "name": "property_halted_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technical_approved_measures_for_install": { + "name": "technical_approved_measures_for_install", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_to_installer_for_pricing": { + "name": "sent_to_installer_for_pricing", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "domna_survey_required": { + "name": "domna_survey_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "domna_survey_type": { + "name": "domna_survey_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domna_survey_date": { + "name": "domna_survey_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "date_booking_made": { + "name": "date_booking_made", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_contact_date": { + "name": "last_contact_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_outbound_call": { + "name": "last_outbound_call", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_outbound_email": { + "name": "last_outbound_email", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_submission_date": { + "name": "last_submission_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "number_of_attempts": { + "name": "number_of_attempts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hubspot_projects_data": { + "name": "hubspot_projects_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hubspot_projects_data_project_id_unique": { + "name": "hubspot_projects_data_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hubspot_users": { + "name": "hubspot_users", + "schema": "", + "columns": { + "hubspot_owner_id": { + "name": "hubspot_owner_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_status_tracker": { + "name": "property_status_tracker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "property_status_tracker_property_id_property_id_fk": { + "name": "property_status_tracker_property_id_property_id_fk", + "tableFrom": "property_status_tracker", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_status_tracker_portfolio_id_portfolio_id_fk": { + "name": "property_status_tracker_portfolio_id_portfolio_id_fk", + "tableFrom": "property_status_tracker", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.energy_assessments": { + "name": "energy_assessments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uprn_source": { + "name": "uprn_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_reference_number": { + "name": "building_reference_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_energy_efficiency": { + "name": "current_energy_efficiency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_energy_rating": { + "name": "current_energy_rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address1": { + "name": "address1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address2": { + "name": "address2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address3": { + "name": "address3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posttown": { + "name": "posttown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency": { + "name": "constituency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency_label": { + "name": "constituency_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "low_energy_fixed_light_count": { + "name": "low_energy_fixed_light_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "construction_age_band": { + "name": "construction_age_band", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheat_energy_eff": { + "name": "mainheat_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_env_eff": { + "name": "windows_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_energy_eff": { + "name": "lighting_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_impact_potential": { + "name": "environment_impact_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatcont_description": { + "name": "mainheatcont_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheating_energy_eff": { + "name": "sheating_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_authority": { + "name": "local_authority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_authority_label": { + "name": "local_authority_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fixed_lighting_outlets_count": { + "name": "fixed_lighting_outlets_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_tariff": { + "name": "energy_tariff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mechanical_ventilation": { + "name": "mechanical_ventilation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "solar_water_heating_flag": { + "name": "solar_water_heating_flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emissions_potential": { + "name": "co2_emissions_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_heated_rooms": { + "name": "number_heated_rooms", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_description": { + "name": "floor_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_consumption_potential": { + "name": "energy_consumption_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "built_form": { + "name": "built_form", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_open_fireplaces": { + "name": "number_open_fireplaces", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_description": { + "name": "windows_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "glazed_area": { + "name": "glazed_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inspection_date": { + "name": "inspection_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "mains_gas_flag": { + "name": "mains_gas_flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emiss_curr_per_floor_area": { + "name": "co2_emiss_curr_per_floor_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heat_loss_corridor": { + "name": "heat_loss_corridor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unheated_corridor_length": { + "name": "unheated_corridor_length", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flat_storey_count": { + "name": "flat_storey_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roof_energy_eff": { + "name": "roof_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_floor_area": { + "name": "total_floor_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_impact_current": { + "name": "environment_impact_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roof_description": { + "name": "roof_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_energy_eff": { + "name": "floor_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_habitable_rooms": { + "name": "number_habitable_rooms", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_env_eff": { + "name": "hot_water_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatc_energy_eff": { + "name": "mainheatc_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "main_fuel": { + "name": "main_fuel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_env_eff": { + "name": "lighting_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_energy_eff": { + "name": "windows_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_env_eff": { + "name": "floor_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheating_env_eff": { + "name": "sheating_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_description": { + "name": "lighting_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roof_env_eff": { + "name": "roof_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_energy_eff": { + "name": "walls_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_supply": { + "name": "photo_supply", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_cost_potential": { + "name": "lighting_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheat_env_eff": { + "name": "mainheat_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "multi_glaze_proportion": { + "name": "multi_glaze_proportion", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "main_heating_controls": { + "name": "main_heating_controls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flat_top_storey": { + "name": "flat_top_storey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secondheat_description": { + "name": "secondheat_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_env_eff": { + "name": "walls_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension_count": { + "name": "extension_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatc_env_eff": { + "name": "mainheatc_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lmk_key": { + "name": "lmk_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wind_turbine_count": { + "name": "wind_turbine_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenure": { + "name": "tenure", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_level": { + "name": "floor_level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "potential_energy_efficiency": { + "name": "potential_energy_efficiency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "potential_energy_rating": { + "name": "potential_energy_rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_energy_eff": { + "name": "hot_water_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "low_energy_lighting": { + "name": "low_energy_lighting", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_description": { + "name": "walls_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hotwater_description": { + "name": "hotwater_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emissions_current": { + "name": "co2_emissions_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heating_cost_current": { + "name": "heating_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heating_cost_potential": { + "name": "heating_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_cost_current": { + "name": "hot_water_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_cost_potential": { + "name": "hot_water_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_cost_current": { + "name": "lighting_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_consumption_current": { + "name": "energy_consumption_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lodgement_date": { + "name": "lodgement_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "lodgement_datetime": { + "name": "lodgement_datetime", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true + }, + "mainheat_description": { + "name": "mainheat_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_height": { + "name": "floor_height", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "glazed_type": { + "name": "glazed_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_location": { + "name": "file_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surveyor_name": { + "name": "surveyor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surveyor_company": { + "name": "surveyor_company", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "space_heating_kwh": { + "name": "space_heating_kwh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "water_heating_kwh": { + "name": "water_heating_kwh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_of_doors": { + "name": "number_of_doors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number_of_insulated_doors": { + "name": "number_of_insulated_doors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number_of_floors": { + "name": "number_of_floors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "insulation_wall_area": { + "name": "insulation_wall_area", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "heat_loss_perimeter": { + "name": "heat_loss_perimeter", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "party_wall_length": { + "name": "party_wall_length", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "perimeter": { + "name": "perimeter", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "rooms_with_bath_and_or_shower": { + "name": "rooms_with_bath_and_or_shower", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rooms_with_mixer_shower_no_bath": { + "name": "rooms_with_mixer_shower_no_bath", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "room_with_bath_and_mixer_shower": { + "name": "room_with_bath_and_mixer_shower", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent_draftproofed": { + "name": "percent_draftproofed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_hot_water_cylinder": { + "name": "has_hot_water_cylinder", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cylinder_insulation_type": { + "name": "cylinder_insulation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cylinder_insulation_thickness": { + "name": "cylinder_insulation_thickness", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cylinder_thermostat": { + "name": "cylinder_thermostat", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "main_dwelling_ground_floor_area": { + "name": "main_dwelling_ground_floor_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_windows": { + "name": "number_of_windows", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "windows_area": { + "name": "windows_area", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.energy_assessment_documents": { + "name": "energy_assessment_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "energy_assessment_id": { + "name": "energy_assessment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "document_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "document_location": { + "name": "document_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scenario_id": { + "name": "scenario_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk": { + "name": "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk", + "tableFrom": "energy_assessment_documents", + "tableTo": "energy_assessments", + "columnsFrom": [ + "energy_assessment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk": { + "name": "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk", + "tableFrom": "energy_assessment_documents", + "tableTo": "energy_assessment_scenarios", + "columnsFrom": [ + "scenario_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.energy_assessment_scenarios": { + "name": "energy_assessment_scenarios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "scenario_name": { + "name": "scenario_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_assessment_id": { + "name": "energy_assessment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk": { + "name": "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk", + "tableFrom": "energy_assessment_scenarios", + "tableTo": "energy_assessments", + "columnsFrom": [ + "energy_assessment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_store": { + "name": "epc_store", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "epc_api_created_at": { + "name": "epc_api_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "epc_api": { + "name": "epc_api", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "epc_page_created_at": { + "name": "epc_page_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "epc_page": { + "name": "epc_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_page_rrn": { + "name": "epc_page_rrn", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_epc_store_uprn": { + "name": "uq_epc_store_uprn", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files_from_surveyor": { + "name": "files_from_surveyor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "s3_json_url": { + "name": "s3_json_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "files_from_surveyor_portfolio_id_portfolio_id_fk": { + "name": "files_from_surveyor_portfolio_id_portfolio_id_fk", + "tableFrom": "files_from_surveyor", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_from_surveyor_property_id_property_id_fk": { + "name": "files_from_surveyor_property_id_property_id_fk", + "tableFrom": "files_from_surveyor", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_package": { + "name": "funding_package", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scheme": { + "name": "scheme", + "type": "scheme", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "project_funding": { + "name": "project_funding", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_uplift": { + "name": "total_uplift", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "full_project_score": { + "name": "full_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "partial_project_score": { + "name": "partial_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "uplift_project_score": { + "name": "uplift_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "funding_package_plan_id_plan_id_fk": { + "name": "funding_package_plan_id_plan_id_fk", + "tableFrom": "funding_package", + "tableTo": "plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_package_measures": { + "name": "funding_package_measures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "funding_package_id": { + "name": "funding_package_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "measure": { + "name": "measure", + "type": "type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "innovation_uplift": { + "name": "innovation_uplift", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "partial_project_score": { + "name": "partial_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "uplift_project_score": { + "name": "uplift_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "funding_package_measures_funding_package_id_funding_package_id_fk": { + "name": "funding_package_measures_funding_package_id_funding_package_id_fk", + "tableFrom": "funding_package_measures", + "tableTo": "funding_package", + "columnsFrom": [ + "funding_package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "funding_package_measures_material_id_material_id_fk": { + "name": "funding_package_measures_material_id_material_id_fk", + "tableFrom": "funding_package_measures", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inspections": { + "name": "inspections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "archetype": { + "name": "archetype", + "type": "inspection_archetype", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "archetype_2": { + "name": "archetype_2", + "type": "inspection_archetype_2", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "wall_construction": { + "name": "wall_construction", + "type": "inspections_wall_construction", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "insulation": { + "name": "insulation", + "type": "inspections_wall_insulation", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "insulation_material": { + "name": "insulation_material", + "type": "inspections_insulation_material", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "borescoped": { + "name": "borescoped", + "type": "inspection_borescoped", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "roof_orientation": { + "name": "roof_orientation", + "type": "inspections_roof_orientation", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tile_hung": { + "name": "tile_hung", + "type": "inspections_tile_hung", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "rendered": { + "name": "rendered", + "type": "inspections_rendered", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cladding": { + "name": "cladding", + "type": "inspections_cladding", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "access_issues": { + "name": "access_issues", + "type": "inspections_access_issues", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "surveyor_name": { + "name": "surveyor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inspections_property_id_property_id_fk": { + "name": "inspections_property_id_property_id_fk", + "tableFrom": "inspections", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_built_form_type_overrides": { + "name": "landlord_built_form_type_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "built_form_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_built_form_type_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_built_form_type_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_built_form_type_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_built_form_type_overrides_portfolio_description_unique": { + "name": "landlord_built_form_type_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_construction_age_band_overrides": { + "name": "landlord_construction_age_band_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "construction_age_band", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_construction_age_band_overrides_portfolio_fk": { + "name": "landlord_construction_age_band_overrides_portfolio_fk", + "tableFrom": "landlord_construction_age_band_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_construction_age_band_portfolio_description_unique": { + "name": "landlord_construction_age_band_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_glazing_overrides": { + "name": "landlord_glazing_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "glazing", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_glazing_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_glazing_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_glazing_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_glazing_overrides_portfolio_description_unique": { + "name": "landlord_glazing_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_main_fuel_overrides": { + "name": "landlord_main_fuel_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "main_fuel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_main_fuel_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_main_fuel_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_main_fuel_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_main_fuel_overrides_portfolio_description_unique": { + "name": "landlord_main_fuel_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_main_heating_system_overrides": { + "name": "landlord_main_heating_system_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "main_heating_system", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_main_heating_system_overrides_portfolio_fk": { + "name": "landlord_main_heating_system_overrides_portfolio_fk", + "tableFrom": "landlord_main_heating_system_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_main_heating_system_portfolio_description_unique": { + "name": "landlord_main_heating_system_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_property_type_overrides": { + "name": "landlord_property_type_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "property_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_property_type_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_property_type_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_property_type_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_property_type_overrides_portfolio_description_unique": { + "name": "landlord_property_type_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_roof_type_overrides": { + "name": "landlord_roof_type_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "roof_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_roof_type_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_roof_type_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_roof_type_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_roof_type_overrides_portfolio_description_unique": { + "name": "landlord_roof_type_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_wall_type_overrides": { + "name": "landlord_wall_type_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "wall_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_wall_type_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_wall_type_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_wall_type_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_wall_type_overrides_portfolio_description_unique": { + "name": "landlord_wall_type_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.landlord_water_heating_overrides": { + "name": "landlord_water_heating_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "water_heating", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "override_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "landlord_water_heating_overrides_portfolio_id_portfolio_id_fk": { + "name": "landlord_water_heating_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "landlord_water_heating_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "landlord_water_heating_overrides_portfolio_description_unique": { + "name": "landlord_water_heating_overrides_portfolio_description_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "description" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_door": { + "name": "magic_plan_door", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_room_id": { + "name": "magic_plan_room_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "width_mm": { + "name": "width_mm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "height_mm": { + "name": "height_mm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_door_magic_plan_room_id_magic_plan_room_id_fk": { + "name": "magic_plan_door_magic_plan_room_id_magic_plan_room_id_fk", + "tableFrom": "magic_plan_door", + "tableTo": "magic_plan_room", + "columnsFrom": [ + "magic_plan_room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_door_ventilation": { + "name": "magic_plan_door_ventilation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_door_id": { + "name": "magic_plan_door_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "undercut_mm": { + "name": "undercut_mm", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_door_ventilation_magic_plan_door_id_magic_plan_door_id_fk": { + "name": "magic_plan_door_ventilation_magic_plan_door_id_magic_plan_door_id_fk", + "tableFrom": "magic_plan_door_ventilation", + "tableTo": "magic_plan_door", + "columnsFrom": [ + "magic_plan_door_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_plan_door_ventilation_magic_plan_door_id_unique": { + "name": "magic_plan_door_ventilation_magic_plan_door_id_unique", + "nullsNotDistinct": false, + "columns": [ + "magic_plan_door_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_floor": { + "name": "magic_plan_floor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_plan_id": { + "name": "magic_plan_plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_floor_magic_plan_plan_id_magic_plan_plan_id_fk": { + "name": "magic_plan_floor_magic_plan_plan_id_magic_plan_plan_id_fk", + "tableFrom": "magic_plan_floor", + "tableTo": "magic_plan_plan", + "columnsFrom": [ + "magic_plan_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_plan": { + "name": "magic_plan_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "magic_plan_uid": { + "name": "magic_plan_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_file_id": { + "name": "uploaded_file_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_plan_uploaded_file_id_uploaded_files_id_fk": { + "name": "magic_plan_plan_uploaded_file_id_uploaded_files_id_fk", + "tableFrom": "magic_plan_plan", + "tableTo": "uploaded_files", + "columnsFrom": [ + "uploaded_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_plan_plan_magic_plan_uid_unique": { + "name": "magic_plan_plan_magic_plan_uid_unique", + "nullsNotDistinct": false, + "columns": [ + "magic_plan_uid" + ] + }, + "magic_plan_plan_uploaded_file_id_unique": { + "name": "magic_plan_plan_uploaded_file_id_unique", + "nullsNotDistinct": false, + "columns": [ + "uploaded_file_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_room": { + "name": "magic_plan_room", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_floor_id": { + "name": "magic_plan_floor_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width_m": { + "name": "width_m", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "length_m": { + "name": "length_m", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "area_m2": { + "name": "area_m2", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_room_magic_plan_floor_id_magic_plan_floor_id_fk": { + "name": "magic_plan_room_magic_plan_floor_id_magic_plan_floor_id_fk", + "tableFrom": "magic_plan_room", + "tableTo": "magic_plan_floor", + "columnsFrom": [ + "magic_plan_floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_window": { + "name": "magic_plan_window", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_room_id": { + "name": "magic_plan_room_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "width_m": { + "name": "width_m", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "height_m": { + "name": "height_m", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "area_m2": { + "name": "area_m2", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_window_magic_plan_room_id_magic_plan_room_id_fk": { + "name": "magic_plan_window_magic_plan_room_id_magic_plan_room_id_fk", + "tableFrom": "magic_plan_window", + "tableTo": "magic_plan_room", + "columnsFrom": [ + "magic_plan_room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_plan_window_ventilation": { + "name": "magic_plan_window_ventilation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "magic_plan_window_id": { + "name": "magic_plan_window_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "opening_type": { + "name": "opening_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "num_openings": { + "name": "num_openings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pct_openable": { + "name": "pct_openable", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trickle_vent_area_mm2": { + "name": "trickle_vent_area_mm2", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "num_trickle_vents": { + "name": "num_trickle_vents", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "magic_plan_window_ventilation_magic_plan_window_id_magic_plan_window_id_fk": { + "name": "magic_plan_window_ventilation_magic_plan_window_id_magic_plan_window_id_fk", + "tableFrom": "magic_plan_window_ventilation", + "tableTo": "magic_plan_window", + "columnsFrom": [ + "magic_plan_window_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_plan_window_ventilation_magic_plan_window_id_unique": { + "name": "magic_plan_window_ventilation_magic_plan_window_id_unique", + "nullsNotDistinct": false, + "columns": [ + "magic_plan_window_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material": { + "name": "material", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "depth": { + "name": "depth", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "depth_unit": { + "name": "depth_unit", + "type": "depth_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cost_unit": { + "name": "cost_unit", + "type": "cost_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "r_value_per_mm": { + "name": "r_value_per_mm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "r_value_unit": { + "name": "r_value_unit", + "type": "r_value_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "thermal_conductivity": { + "name": "thermal_conductivity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "thermal_conductivity_unit": { + "name": "thermal_conductivity_unit", + "type": "thermal_conductivity_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "prime_material_cost": { + "name": "prime_material_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "material_cost": { + "name": "material_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_cost": { + "name": "labour_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_hours_per_unit": { + "name": "labour_hours_per_unit", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "plant_cost": { + "name": "plant_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_installer_quote": { + "name": "is_installer_quote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "innovation_rate": { + "name": "innovation_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "size": { + "name": "size", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "size_unit": { + "name": "size_unit", + "type": "size_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "includes_scaffolding": { + "name": "includes_scaffolding", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "includes_battery": { + "name": "includes_battery", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "battery_size": { + "name": "battery_size", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_material_active": { + "name": "idx_material_active", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"material\".\"is_active\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hubspot_company_id": { + "name": "hubspot_company_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pibi_requests": { + "name": "pibi_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordered_at": { + "name": "ordered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pushed_at": { + "name": "pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_pibi_requests_deal_id": { + "name": "idx_pibi_requests_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pibi_requests_portfolio_id": { + "name": "idx_pibi_requests_portfolio_id", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pibi_requests_portfolio_id_portfolio_id_fk": { + "name": "pibi_requests_portfolio_id_portfolio_id_fk", + "tableFrom": "pibi_requests", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pibi_requests_created_by_user_id_user_id_fk": { + "name": "pibi_requests_created_by_user_id_user_id_fk", + "tableFrom": "pibi_requests", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_organisation": { + "name": "portfolio_organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolio_organisation_portfolio_id_portfolio_id_fk": { + "name": "portfolio_organisation_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolio_organisation", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolio_organisation_organisation_id_organisation_id_fk": { + "name": "portfolio_organisation_organisation_id_organisation_id_fk", + "tableFrom": "portfolio_organisation", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "portfolio_organisation_portfolio_id_organisation_id_unique": { + "name": "portfolio_organisation_portfolio_id_organisation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "organisation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio": { + "name": "portfolio", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "budget": { + "name": "budget", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "goal", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_properties": { + "name": "number_of_properties", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rental_yield_increase": { + "name": "rental_yield_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "epc_breakdown_pre_retrofit": { + "name": "epc_breakdown_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_post_retrofit": { + "name": "epc_breakdown_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "n_units_to_retrofit": { + "name": "n_units_to_retrofit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_pre_retrofit": { + "name": "co2_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_post_retrofit": { + "name": "co2_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_pre_retrofit": { + "name": "energy_bill_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_post_retrofit": { + "name": "energy_bill_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_pre_retrofit": { + "name": "energy_consumption_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_post_retrofit": { + "name": "energy_consumption_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_improvement_per_unit": { + "name": "valuation_improvement_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_unit": { + "name": "cost_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_co2_saved": { + "name": "cost_per_co2_saved", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_sap_point": { + "name": "cost_per_sap_point", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_return_on_investment": { + "name": "valuation_return_on_investment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_capabilities": { + "name": "portfolio_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "portfolio_capability", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolio_capabilities_user_id_user_id_fk": { + "name": "portfolio_capabilities_user_id_user_id_fk", + "tableFrom": "portfolio_capabilities", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "portfolio_capabilities_portfolio_id_portfolio_id_fk": { + "name": "portfolio_capabilities_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolio_capabilities", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "portfolio_capabilities_user_id_portfolio_id_capability_unique": { + "name": "portfolio_capabilities_user_id_portfolio_id_capability_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "portfolio_id", + "capability" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolioInvitations": { + "name": "portfolioInvitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolioInvitations_portfolio_id_portfolio_id_fk": { + "name": "portfolioInvitations_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolioInvitations", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolioInvitations_invited_by_user_id_user_id_fk": { + "name": "portfolioInvitations_invited_by_user_id_user_id_fk", + "tableFrom": "portfolioInvitations", + "tableTo": "user", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "portfolio_invitations_portfolio_email_unique": { + "name": "portfolio_invitations_portfolio_email_unique", + "nullsNotDistinct": false, + "columns": [ + "portfolio_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolioUsers": { + "name": "portfolioUsers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolioUsers_user_id_user_id_fk": { + "name": "portfolioUsers_user_id_user_id_fk", + "tableFrom": "portfolioUsers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "portfolioUsers_portfolio_id_portfolio_id_fk": { + "name": "portfolioUsers_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolioUsers", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_overrides": { + "name": "property_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "building_part": { + "name": "building_part", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "override_component": { + "name": "override_component", + "type": "override_component", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "override_value": { + "name": "override_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_spreadsheet_description": { + "name": "original_spreadsheet_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "property_overrides_property_id_property_id_fk": { + "name": "property_overrides_property_id_property_id_fk", + "tableFrom": "property_overrides", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_overrides_portfolio_id_portfolio_id_fk": { + "name": "property_overrides_portfolio_id_portfolio_id_fk", + "tableFrom": "property_overrides", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "property_overrides_property_component_part_unique": { + "name": "property_overrides_property_component_part_unique", + "nullsNotDistinct": false, + "columns": [ + "property_id", + "override_component", + "building_part" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_building_part": { + "name": "epc_building_part", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "construction_age_band": { + "name": "construction_age_band", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wall_construction": { + "name": "wall_construction", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "wall_insulation_type": { + "name": "wall_insulation_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "wall_thickness_measured": { + "name": "wall_thickness_measured", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "party_wall_construction": { + "name": "party_wall_construction", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "building_part_number": { + "name": "building_part_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_dry_lined": { + "name": "wall_dry_lined", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "wall_thickness_mm": { + "name": "wall_thickness_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_insulation_thickness": { + "name": "wall_insulation_thickness", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "wall_insulation_thermal_conductivity": { + "name": "wall_insulation_thermal_conductivity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "floor_heat_loss": { + "name": "floor_heat_loss", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floor_insulation_thickness": { + "name": "floor_insulation_thickness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flat_roof_insulation_thickness": { + "name": "flat_roof_insulation_thickness", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "floor_type": { + "name": "floor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_construction_type": { + "name": "floor_construction_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_insulation_type_str": { + "name": "floor_insulation_type_str", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_u_value_known": { + "name": "floor_u_value_known", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "roof_construction": { + "name": "roof_construction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "roof_insulation_location": { + "name": "roof_insulation_location", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "roof_insulation_thickness": { + "name": "roof_insulation_thickness", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "roof_construction_type": { + "name": "roof_construction_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "curtain_wall_age": { + "name": "curtain_wall_age", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "room_in_roof_floor_area": { + "name": "room_in_roof_floor_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "room_in_roof_construction_age_band": { + "name": "room_in_roof_construction_age_band", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_area": { + "name": "alt_wall_1_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_dry_lined": { + "name": "alt_wall_1_dry_lined", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_construction": { + "name": "alt_wall_1_construction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_insulation_type": { + "name": "alt_wall_1_insulation_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_thickness_measured": { + "name": "alt_wall_1_thickness_measured", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_insulation_thickness": { + "name": "alt_wall_1_insulation_thickness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_1_is_sheltered": { + "name": "alt_wall_1_is_sheltered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_area": { + "name": "alt_wall_2_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_dry_lined": { + "name": "alt_wall_2_dry_lined", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_construction": { + "name": "alt_wall_2_construction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_insulation_type": { + "name": "alt_wall_2_insulation_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_thickness_measured": { + "name": "alt_wall_2_thickness_measured", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_insulation_thickness": { + "name": "alt_wall_2_insulation_thickness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt_wall_2_is_sheltered": { + "name": "alt_wall_2_is_sheltered", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ix_epc_building_part_epc_property": { + "name": "ix_epc_building_part_epc_property", + "columns": [ + { + "expression": "epc_property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "epc_building_part_epc_property_id_epc_property_id_fk": { + "name": "epc_building_part_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_building_part", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_energy_element": { + "name": "epc_energy_element", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "element_type": { + "name": "element_type", + "type": "energy_element_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_efficiency_rating": { + "name": "energy_efficiency_rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "environmental_efficiency_rating": { + "name": "environmental_efficiency_rating", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ix_epc_energy_element_epc_property": { + "name": "ix_epc_energy_element_epc_property", + "columns": [ + { + "expression": "epc_property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "epc_energy_element_epc_property_id_epc_property_id_fk": { + "name": "epc_energy_element_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_energy_element", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_flat_details": { + "name": "epc_flat_details", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "top_storey": { + "name": "top_storey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flat_location": { + "name": "flat_location", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "heat_loss_corridor": { + "name": "heat_loss_corridor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storey_count": { + "name": "storey_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unheated_corridor_length_m": { + "name": "unheated_corridor_length_m", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_flat_details_epc_property_id_epc_property_id_fk": { + "name": "epc_flat_details_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_flat_details", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "epc_flat_details_epc_property_id_unique": { + "name": "epc_flat_details_epc_property_id_unique", + "nullsNotDistinct": false, + "columns": [ + "epc_property_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_floor_dimension": { + "name": "epc_floor_dimension", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_building_part_id": { + "name": "epc_building_part_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "room_height_m": { + "name": "room_height_m", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "total_floor_area_m2": { + "name": "total_floor_area_m2", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "party_wall_length_m": { + "name": "party_wall_length_m", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "heat_loss_perimeter_m": { + "name": "heat_loss_perimeter_m", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "floor_insulation": { + "name": "floor_insulation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floor_construction": { + "name": "floor_construction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_exposed_floor": { + "name": "is_exposed_floor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_above_partially_heated_space": { + "name": "is_above_partially_heated_space", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "ix_epc_floor_dimension_building_part": { + "name": "ix_epc_floor_dimension_building_part", + "columns": [ + { + "expression": "epc_building_part_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "epc_floor_dimension_epc_building_part_id_epc_building_part_id_fk": { + "name": "epc_floor_dimension_epc_building_part_id_epc_building_part_id_fk", + "tableFrom": "epc_floor_dimension", + "tableTo": "epc_building_part", + "columnsFrom": [ + "epc_building_part_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_main_heating_detail": { + "name": "epc_main_heating_detail", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "has_fghrs": { + "name": "has_fghrs", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "main_fuel_type": { + "name": "main_fuel_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "heat_emitter_type": { + "name": "heat_emitter_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "emitter_temperature": { + "name": "emitter_temperature", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "main_heating_control": { + "name": "main_heating_control", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fan_flue_present": { + "name": "fan_flue_present", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boiler_flue_type": { + "name": "boiler_flue_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "boiler_ignition_type": { + "name": "boiler_ignition_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "central_heating_pump_age": { + "name": "central_heating_pump_age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "central_heating_pump_age_str": { + "name": "central_heating_pump_age_str", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "main_heating_index_number": { + "name": "main_heating_index_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sap_main_heating_code": { + "name": "sap_main_heating_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "main_heating_number": { + "name": "main_heating_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "main_heating_category": { + "name": "main_heating_category", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "main_heating_fraction": { + "name": "main_heating_fraction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "main_heating_data_source": { + "name": "main_heating_data_source", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "condensing": { + "name": "condensing", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "weather_compensator": { + "name": "weather_compensator", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "community_heating_boiler_fuel_type": { + "name": "community_heating_boiler_fuel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "community_heating_chp_fraction": { + "name": "community_heating_chp_fraction", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_main_heating_detail_epc_property_id_epc_property_id_fk": { + "name": "epc_main_heating_detail_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_main_heating_detail", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_photovoltaic_array": { + "name": "epc_photovoltaic_array", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "array_index": { + "name": "array_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "peak_power": { + "name": "peak_power", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "pitch": { + "name": "pitch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "overshading": { + "name": "overshading", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "orientation": { + "name": "orientation", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_photovoltaic_array_epc_property_id_epc_property_id_fk": { + "name": "epc_photovoltaic_array_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_photovoltaic_array", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_property": { + "name": "epc_property", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "uploaded_file_id": { + "name": "uploaded_file_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lodged'" + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "uprn_source": { + "name": "uprn_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_reference": { + "name": "report_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assessment_type": { + "name": "assessment_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sap_version": { + "name": "sap_version", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "schema_type": { + "name": "schema_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_versions_original": { + "name": "schema_versions_original", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calculation_software_version": { + "name": "calculation_software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_1": { + "name": "address_line_1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address_line_2": { + "name": "address_line_2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "post_town": { + "name": "post_town", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "region_code": { + "name": "region_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dwelling_type": { + "name": "dwelling_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "built_form": { + "name": "built_form", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenure": { + "name": "tenure", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inspection_date": { + "name": "inspection_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completion_date": { + "name": "completion_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "registration_date": { + "name": "registration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_floor_area_m2": { + "name": "total_floor_area_m2", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "measurement_type": { + "name": "measurement_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "solar_water_heating": { + "name": "solar_water_heating", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "has_hot_water_cylinder": { + "name": "has_hot_water_cylinder", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "has_fixed_air_conditioning": { + "name": "has_fixed_air_conditioning", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "has_conservatory": { + "name": "has_conservatory", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_heated_separate_conservatory": { + "name": "has_heated_separate_conservatory", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "conservatory_type": { + "name": "conservatory_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conservatory_floor_area_m2": { + "name": "conservatory_floor_area_m2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "conservatory_glazed_perimeter_m": { + "name": "conservatory_glazed_perimeter_m", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "conservatory_double_glazed": { + "name": "conservatory_double_glazed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "conservatory_thermally_separated": { + "name": "conservatory_thermally_separated", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "conservatory_room_height_storeys": { + "name": "conservatory_room_height_storeys", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "door_count": { + "name": "door_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wet_rooms_count": { + "name": "wet_rooms_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "extensions_count": { + "name": "extensions_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "heated_rooms_count": { + "name": "heated_rooms_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "open_chimneys_count": { + "name": "open_chimneys_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "habitable_rooms_count": { + "name": "habitable_rooms_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "insulated_door_count": { + "name": "insulated_door_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cfl_fixed_lighting_bulbs_count": { + "name": "cfl_fixed_lighting_bulbs_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "led_fixed_lighting_bulbs_count": { + "name": "led_fixed_lighting_bulbs_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "incandescent_fixed_lighting_bulbs_count": { + "name": "incandescent_fixed_lighting_bulbs_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blocked_chimneys_count": { + "name": "blocked_chimneys_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "draughtproofed_door_count": { + "name": "draughtproofed_door_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "energy_rating_average": { + "name": "energy_rating_average", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "low_energy_fixed_lighting_bulbs_count": { + "name": "low_energy_fixed_lighting_bulbs_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fixed_lighting_outlets_count": { + "name": "fixed_lighting_outlets_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "low_energy_fixed_lighting_outlets_count": { + "name": "low_energy_fixed_lighting_outlets_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_storeys": { + "name": "number_of_storeys", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "any_unheated_rooms": { + "name": "any_unheated_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hydro": { + "name": "hydro", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "photovoltaic_array": { + "name": "photovoltaic_array", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "waste_water_heat_recovery": { + "name": "waste_water_heat_recovery", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pressure_test": { + "name": "pressure_test", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pressure_test_certificate_number": { + "name": "pressure_test_certificate_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent_draughtproofed": { + "name": "percent_draughtproofed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "insulated_door_u_value": { + "name": "insulated_door_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "multiple_glazed_proportion": { + "name": "multiple_glazed_proportion", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "windows_transmission_u_value": { + "name": "windows_transmission_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "windows_transmission_data_source": { + "name": "windows_transmission_data_source", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "windows_transmission_solar_transmittance": { + "name": "windows_transmission_solar_transmittance", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_gas_connection_available": { + "name": "energy_gas_connection_available", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "energy_meter_type": { + "name": "energy_meter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_pv_battery_count": { + "name": "energy_pv_battery_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "energy_wind_turbines_count": { + "name": "energy_wind_turbines_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "energy_gas_smart_meter_present": { + "name": "energy_gas_smart_meter_present", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "energy_is_dwelling_export_capable": { + "name": "energy_is_dwelling_export_capable", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "energy_wind_turbines_terrain_type": { + "name": "energy_wind_turbines_terrain_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_electricity_smart_meter_present": { + "name": "energy_electricity_smart_meter_present", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "energy_pv_connection": { + "name": "energy_pv_connection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "energy_pv_percent_roof_area": { + "name": "energy_pv_percent_roof_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "energy_pv_battery_capacity": { + "name": "energy_pv_battery_capacity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_wind_turbine_hub_height": { + "name": "energy_wind_turbine_hub_height", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_wind_turbine_rotor_diameter": { + "name": "energy_wind_turbine_rotor_diameter", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_pv_diverter_present": { + "name": "energy_pv_diverter_present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "heating_cylinder_size": { + "name": "heating_cylinder_size", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_water_heating_code": { + "name": "heating_water_heating_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_water_heating_fuel": { + "name": "heating_water_heating_fuel", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_immersion_heating_type": { + "name": "heating_immersion_heating_type", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_insulation_type": { + "name": "heating_cylinder_insulation_type", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_thermostat": { + "name": "heating_cylinder_thermostat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_secondary_fuel_type": { + "name": "heating_secondary_fuel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_secondary_heating_type": { + "name": "heating_secondary_heating_type", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_insulation_thickness_mm": { + "name": "heating_cylinder_insulation_thickness_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_volume_measured_l": { + "name": "heating_cylinder_volume_measured_l", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_wwhrs_index_number_1": { + "name": "heating_wwhrs_index_number_1", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_wwhrs_index_number_2": { + "name": "heating_wwhrs_index_number_2", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_shower_outlet_type": { + "name": "heating_shower_outlet_type", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_shower_wwhrs": { + "name": "heating_shower_wwhrs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_type": { + "name": "ventilation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ventilation_draught_lobby": { + "name": "ventilation_draught_lobby", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ventilation_pressure_test": { + "name": "ventilation_pressure_test", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ventilation_open_flues_count": { + "name": "ventilation_open_flues_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_closed_flues_count": { + "name": "ventilation_closed_flues_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_boiler_flues_count": { + "name": "ventilation_boiler_flues_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_other_flues_count": { + "name": "ventilation_other_flues_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_extract_fans_count": { + "name": "ventilation_extract_fans_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_passive_vents_count": { + "name": "ventilation_passive_vents_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_flueless_gas_fires_count": { + "name": "ventilation_flueless_gas_fires_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_in_pcdf_database": { + "name": "ventilation_in_pcdf_database", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "mechanical_ventilation": { + "name": "mechanical_ventilation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_duct_type": { + "name": "mechanical_vent_duct_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_duct_placement": { + "name": "mechanical_vent_duct_placement", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_duct_insulation": { + "name": "mechanical_vent_duct_insulation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_ventilation_index_number": { + "name": "mechanical_ventilation_index_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_measured_installation": { + "name": "mechanical_vent_measured_installation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mechanical_vent_duct_insulation_level": { + "name": "mechanical_vent_duct_insulation_level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "addendum_stone_walls": { + "name": "addendum_stone_walls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "addendum_system_build": { + "name": "addendum_system_build", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "addendum_numbers": { + "name": "addendum_numbers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "heating_number_baths": { + "name": "heating_number_baths", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_number_baths_wwhrs": { + "name": "heating_number_baths_wwhrs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_electric_shower_count": { + "name": "heating_electric_shower_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_mixer_shower_count": { + "name": "heating_mixer_shower_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_present": { + "name": "ventilation_present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ventilation_sheltered_sides": { + "name": "ventilation_sheltered_sides", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ventilation_has_suspended_timber_floor": { + "name": "ventilation_has_suspended_timber_floor", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ventilation_suspended_timber_floor_sealed": { + "name": "ventilation_suspended_timber_floor_sealed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ventilation_has_draught_lobby": { + "name": "ventilation_has_draught_lobby", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ventilation_air_permeability_ap4_m3_h_m2": { + "name": "ventilation_air_permeability_ap4_m3_h_m2", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ventilation_air_permeability_ap50_m3_h_m2": { + "name": "ventilation_air_permeability_ap50_m3_h_m2", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ventilation_mechanical_ventilation_kind": { + "name": "ventilation_mechanical_ventilation_kind", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_epc_property_property_portfolio": { + "name": "uq_epc_property_property_portfolio", + "columns": [ + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ix_epc_property_property_source": { + "name": "ix_epc_property_property_source", + "columns": [ + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "epc_property_property_id_property_id_fk": { + "name": "epc_property_property_id_property_id_fk", + "tableFrom": "epc_property", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "epc_property_portfolio_id_portfolio_id_fk": { + "name": "epc_property_portfolio_id_portfolio_id_fk", + "tableFrom": "epc_property", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "epc_property_uploaded_file_id_uploaded_files_id_fk": { + "name": "epc_property_uploaded_file_id_uploaded_files_id_fk", + "tableFrom": "epc_property", + "tableTo": "uploaded_files", + "columnsFrom": [ + "uploaded_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "epc_property_uploaded_file_id_unique": { + "name": "epc_property_uploaded_file_id_unique", + "nullsNotDistinct": false, + "columns": [ + "uploaded_file_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_property_energy_performance": { + "name": "epc_property_energy_performance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "energy_rating_current": { + "name": "energy_rating_current", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_current": { + "name": "energy_consumption_current", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "environmental_impact_current": { + "name": "environmental_impact_current", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_cost_current": { + "name": "heating_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_current": { + "name": "lighting_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_current": { + "name": "hot_water_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions_current": { + "name": "co2_emissions_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions_current_per_floor_area": { + "name": "co2_emissions_current_per_floor_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_energy_efficiency_band": { + "name": "current_energy_efficiency_band", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_rating_potential": { + "name": "energy_rating_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_potential": { + "name": "energy_consumption_potential", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "environmental_impact_potential": { + "name": "environmental_impact_potential", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heating_cost_potential": { + "name": "heating_cost_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_potential": { + "name": "lighting_cost_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_potential": { + "name": "hot_water_cost_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions_potential": { + "name": "co2_emissions_potential", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "potential_energy_efficiency_band": { + "name": "potential_energy_efficiency_band", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_property_energy_performance_epc_property_id_epc_property_id_fk": { + "name": "epc_property_energy_performance_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_property_energy_performance", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "epc_property_energy_performance_epc_property_id_unique": { + "name": "epc_property_energy_performance_epc_property_id_unique", + "nullsNotDistinct": false, + "columns": [ + "epc_property_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_renewable_heat_incentive": { + "name": "epc_renewable_heat_incentive", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "space_heating_kwh": { + "name": "space_heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "water_heating_kwh": { + "name": "water_heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "impact_of_loft_insulation_kwh": { + "name": "impact_of_loft_insulation_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "impact_of_cavity_insulation_kwh": { + "name": "impact_of_cavity_insulation_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "impact_of_solid_wall_insulation_kwh": { + "name": "impact_of_solid_wall_insulation_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_renewable_heat_incentive_epc_property_id_epc_property_id_fk": { + "name": "epc_renewable_heat_incentive_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_renewable_heat_incentive", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "epc_renewable_heat_incentive_epc_property_id_unique": { + "name": "epc_renewable_heat_incentive_epc_property_id_unique", + "nullsNotDistinct": false, + "columns": [ + "epc_property_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.epc_window": { + "name": "epc_window", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "epc_property_id": { + "name": "epc_property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "glazing_gap": { + "name": "glazing_gap", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "orientation": { + "name": "orientation", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "window_type": { + "name": "window_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "glazing_type": { + "name": "glazing_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "window_width": { + "name": "window_width", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "window_height": { + "name": "window_height", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "draught_proofed": { + "name": "draught_proofed", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "window_location": { + "name": "window_location", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "window_wall_type": { + "name": "window_wall_type", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "permanent_shutters_present": { + "name": "permanent_shutters_present", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "frame_material": { + "name": "frame_material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame_factor": { + "name": "frame_factor", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "permanent_shutters_insulated": { + "name": "permanent_shutters_insulated", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transmission_u_value": { + "name": "transmission_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "transmission_data_source": { + "name": "transmission_data_source", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transmission_solar_transmittance": { + "name": "transmission_solar_transmittance", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "epc_window_epc_property_id_epc_property_id_fk": { + "name": "epc_window_epc_property_id_epc_property_id_fk", + "tableFrom": "epc_window", + "tableTo": "epc_property", + "columnsFrom": [ + "epc_property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.non_intrusive_survey": { + "name": "non_intrusive_survey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "survey_date": { + "name": "survey_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "surveyor": { + "name": "surveyor", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.non_intrusive_survey_notes": { + "name": "non_intrusive_survey_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "survey_id": { + "name": "survey_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk": { + "name": "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk", + "tableFrom": "non_intrusive_survey_notes", + "tableTo": "non_intrusive_survey", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property": { + "name": "property", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "creation_status": { + "name": "creation_status", + "type": "creation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "landlord_property_id": { + "name": "landlord_property_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "building_reference_number": { + "name": "building_reference_number", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_inputted_address": { + "name": "user_inputted_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_inputted_postcode": { + "name": "user_inputted_postcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lexiscore": { + "name": "lexiscore", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "has_pre_condition_report": { + "name": "has_pre_condition_report", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_recommendations": { + "name": "has_recommendations", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "built_form": { + "name": "built_form", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_authority": { + "name": "local_authority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency": { + "name": "constituency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number_of_rooms": { + "name": "number_of_rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year_built": { + "name": "year_built", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenure": { + "name": "tenure", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_epc_rating": { + "name": "current_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "current_sap_points": { + "name": "current_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_valuation": { + "name": "current_valuation", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_sap_point_adjustment": { + "name": "installed_measures_sap_point_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_sap_points_adjusted_for_installed_measures": { + "name": "is_sap_points_adjusted_for_installed_measures", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "original_sap_points": { + "name": "original_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lodged_sap_points": { + "name": "lodged_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lodged_epc_rating": { + "name": "lodged_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "marked_for_deletion": { + "name": "marked_for_deletion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "uq_property_portfolio_uprn": { + "name": "uq_property_portfolio_uprn", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"property\".\"uprn\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "ix_property_portfolio": { + "name": "ix_property_portfolio", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_portfolio_id_portfolio_id_fk": { + "name": "property_portfolio_id_portfolio_id_fk", + "tableFrom": "property", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_baseline_performance": { + "name": "property_baseline_performance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "lodged_sap_score": { + "name": "lodged_sap_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lodged_epc_band": { + "name": "lodged_epc_band", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "lodged_co2_emissions_t_per_yr": { + "name": "lodged_co2_emissions_t_per_yr", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lodged_primary_energy_intensity_kwh_per_m2_yr": { + "name": "lodged_primary_energy_intensity_kwh_per_m2_yr", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "effective_sap_score": { + "name": "effective_sap_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "effective_epc_band": { + "name": "effective_epc_band", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "effective_co2_emissions_t_per_yr": { + "name": "effective_co2_emissions_t_per_yr", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "effective_primary_energy_intensity_kwh_per_m2_yr": { + "name": "effective_primary_energy_intensity_kwh_per_m2_yr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rebaseline_reason": { + "name": "rebaseline_reason", + "type": "rebaseline_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "space_heating_kwh": { + "name": "space_heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "water_heating_kwh": { + "name": "water_heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "fuel_rates_period": { + "name": "fuel_rates_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_kwh": { + "name": "heating_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "heating_cost_gbp": { + "name": "heating_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_kwh": { + "name": "hot_water_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_gbp": { + "name": "hot_water_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_kwh": { + "name": "lighting_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_gbp": { + "name": "lighting_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "appliances_kwh": { + "name": "appliances_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "appliances_cost_gbp": { + "name": "appliances_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cooking_kwh": { + "name": "cooking_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cooking_cost_gbp": { + "name": "cooking_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "pumps_fans_kwh": { + "name": "pumps_fans_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "pumps_fans_cost_gbp": { + "name": "pumps_fans_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cooling_kwh": { + "name": "cooling_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cooling_cost_gbp": { + "name": "cooling_cost_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "standing_charges_gbp": { + "name": "standing_charges_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "seg_credit_gbp": { + "name": "seg_credit_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_annual_bill_gbp": { + "name": "total_annual_bill_gbp", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "property_baseline_performance_property_id_property_id_fk": { + "name": "property_baseline_performance_property_id_property_id_fk", + "tableFrom": "property_baseline_performance", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "property_baseline_performance_property_id_unique": { + "name": "property_baseline_performance_property_id_unique", + "nullsNotDistinct": false, + "columns": [ + "property_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_epc": { + "name": "property_details_epc", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "full_address": { + "name": "full_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lodgement_date": { + "name": "lodgement_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_expired": { + "name": "is_expired", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "total_floor_area": { + "name": "total_floor_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "walls": { + "name": "walls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "walls_rating": { + "name": "walls_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "roof": { + "name": "roof", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roof_rating": { + "name": "roof_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_rating": { + "name": "floor_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "windows": { + "name": "windows", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "windows_rating": { + "name": "windows_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "heating": { + "name": "heating", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_rating": { + "name": "heating_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "heating_controls": { + "name": "heating_controls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_controls_rating": { + "name": "heating_controls_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "hot_water": { + "name": "hot_water", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hot_water_rating": { + "name": "hot_water_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "lighting": { + "name": "lighting", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lighting_rating": { + "name": "lighting_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "mainfuel": { + "name": "mainfuel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ventilation": { + "name": "ventilation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "solar_pv": { + "name": "solar_pv", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "solar_hot_water": { + "name": "solar_hot_water", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "wind_turbine": { + "name": "wind_turbine", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "floor_height": { + "name": "floor_height", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_heated_rooms": { + "name": "number_heated_rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heat_loss_corridor": { + "name": "heat_loss_corridor", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "unheated_corridor_length": { + "name": "unheated_corridor_length", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_open_fireplaces": { + "name": "number_of_open_fireplaces", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_extensions": { + "name": "number_of_extensions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_storeys": { + "name": "number_of_storeys", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mains_gas": { + "name": "mains_gas", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "energy_tariff": { + "name": "energy_tariff", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_energy_consumption": { + "name": "primary_energy_consumption", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions": { + "name": "co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_energy_demand": { + "name": "current_energy_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_energy_demand_heating_hotwater": { + "name": "current_energy_demand_heating_hotwater", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "sap_05_overwritten": { + "name": "sap_05_overwritten", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "sap_05_score": { + "name": "sap_05_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "sap_05_epc_rating": { + "name": "sap_05_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "heating_cost_current": { + "name": "heating_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_current": { + "name": "hot_water_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_current": { + "name": "lighting_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "appliances_cost_current": { + "name": "appliances_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "gas_standing_charge": { + "name": "gas_standing_charge", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "electricity_standing_charge": { + "name": "electricity_standing_charge", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "original_co2_emissions": { + "name": "original_co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "original_primary_energy_consumption": { + "name": "original_primary_energy_consumption", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "original_current_energy_demand": { + "name": "original_current_energy_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "original_current_energy_demand_heating_hotwater": { + "name": "original_current_energy_demand_heating_hotwater", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_co2_adjustment": { + "name": "installed_measures_co2_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_energy_demand_adjustment": { + "name": "installed_measures_energy_demand_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_total_energy_bill_adjustment": { + "name": "installed_measures_total_energy_bill_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "installed_measures_heat_demand_adjustment": { + "name": "installed_measures_heat_demand_adjustment", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_epc_adjusted_for_installed_measures": { + "name": "is_epc_adjusted_for_installed_measures", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "lodged_co2_emissions": { + "name": "lodged_co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lodged_heat_demand": { + "name": "lodged_heat_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "has_been_remodelled": { + "name": "has_been_remodelled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "environment_impact_current": { + "name": "environment_impact_current", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_property_details_epc_property_portfolio": { + "name": "uq_property_details_epc_property_portfolio", + "columns": [ + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_details_epc_property_id_property_id_fk": { + "name": "property_details_epc_property_id_property_id_fk", + "tableFrom": "property_details_epc", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_details_epc_portfolio_id_portfolio_id_fk": { + "name": "property_details_epc_portfolio_id_portfolio_id_fk", + "tableFrom": "property_details_epc", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_meter": { + "name": "property_details_meter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "energy_supplier": { + "name": "energy_supplier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gas_supplier": { + "name": "gas_supplier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meter_reading_total": { + "name": "meter_reading_total", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "meter_reading_electricity": { + "name": "meter_reading_electricity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "meter_reading_gas": { + "name": "meter_reading_gas", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_spatial": { + "name": "property_details_spatial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "x_coordinate": { + "name": "x_coordinate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "y_coordinate": { + "name": "y_coordinate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "conservation_status": { + "name": "conservation_status", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_listed_building": { + "name": "is_listed_building", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_heritage_building": { + "name": "is_heritage_building", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_property_details_spatial_uprn": { + "name": "uq_property_details_spatial_uprn", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_targets": { + "name": "property_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "epc": { + "name": "epc", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "heat_demand": { + "name": "heat_demand", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "property_targets_property_id_property_id_fk": { + "name": "property_targets_property_id_property_id_fk", + "tableFrom": "property_targets", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_targets_portfolio_id_portfolio_id_fk": { + "name": "property_targets_portfolio_id_portfolio_id_fk", + "tableFrom": "property_targets", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.installed_measure": { + "name": "installed_measure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "measure_type": { + "name": "measure_type", + "type": "measure_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "sap_points": { + "name": "sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "carbon_savings": { + "name": "carbon_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "kwh_savings": { + "name": "kwh_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bill_savings": { + "name": "bill_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "heat_demand_savings": { + "name": "heat_demand_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_installed_measure_uprn": { + "name": "idx_installed_measure_uprn", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_measure_uprn_active": { + "name": "idx_installed_measure_uprn_active", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"installed_measure\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_measure_measure_type": { + "name": "idx_installed_measure_measure_type", + "columns": [ + { + "expression": "measure_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_measure_uprn_measure": { + "name": "idx_installed_measure_uprn_measure", + "columns": [ + { + "expression": "uprn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "measure_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"installed_measure\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan": { + "name": "plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "valuation_increase_lower_bound": { + "name": "valuation_increase_lower_bound", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_increase_upper_bound": { + "name": "valuation_increase_upper_bound", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_increase_average": { + "name": "valuation_increase_average", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "post_sap_points": { + "name": "post_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "post_epc_rating": { + "name": "post_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "post_co2_emissions": { + "name": "post_co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_savings": { + "name": "co2_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "post_energy_bill": { + "name": "post_energy_bill", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_bill_savings": { + "name": "energy_bill_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "post_energy_consumption": { + "name": "post_energy_consumption", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_savings": { + "name": "energy_consumption_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_post_retrofit": { + "name": "valuation_post_retrofit", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_increase": { + "name": "valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost_of_works": { + "name": "cost_of_works", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "contingency_cost": { + "name": "contingency_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "plan_type": { + "name": "plan_type", + "type": "plan_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_plan_portfolio_scenario": { + "name": "idx_plan_portfolio_scenario", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plan_latest_per_property": { + "name": "idx_plan_latest_per_property", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_portfolio_id_portfolio_id_fk": { + "name": "plan_portfolio_id_portfolio_id_fk", + "tableFrom": "plan", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_property_id_property_id_fk": { + "name": "plan_property_id_property_id_fk", + "tableFrom": "plan", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_scenario_id_scenario_id_fk": { + "name": "plan_scenario_id_scenario_id_fk", + "tableFrom": "plan", + "tableTo": "scenario", + "columnsFrom": [ + "scenario_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan_recommendations": { + "name": "plan_recommendations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "recommendation_id": { + "name": "recommendation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_plan_recommendations_plan_id": { + "name": "idx_plan_recommendations_plan_id", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plan_recommendations_plan_rec": { + "name": "idx_plan_recommendations_plan_rec", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recommendation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plan_recommendations_recommendation_id": { + "name": "idx_plan_recommendations_recommendation_id", + "columns": [ + { + "expression": "recommendation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_recommendations_plan_id_plan_id_fk": { + "name": "plan_recommendations_plan_id_plan_id_fk", + "tableFrom": "plan_recommendations", + "tableTo": "plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_recommendations_recommendation_id_recommendation_id_fk": { + "name": "plan_recommendations_recommendation_id_recommendation_id_fk", + "tableFrom": "plan_recommendations", + "tableTo": "recommendation", + "columnsFrom": [ + "recommendation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendation": { + "name": "recommendation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "material_id": { + "name": "material_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "material_quantity": { + "name": "material_quantity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "material_quantity_unit": { + "name": "material_quantity_unit", + "type": "unit_quantity", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "material_depth": { + "name": "material_depth", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_type": { + "name": "measure_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "contingency_cost": { + "name": "contingency_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "starting_u_value": { + "name": "starting_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "new_u_value": { + "name": "new_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "sap_points": { + "name": "sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "heat_demand": { + "name": "heat_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "kwh_savings": { + "name": "kwh_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rental_yield_increase": { + "name": "rental_yield_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "already_installed": { + "name": "already_installed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "recommendation_property_id_idx": { + "name": "recommendation_property_id_idx", + "columns": [ + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_recommendation_plan_id": { + "name": "idx_recommendation_plan_id", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_recommendation_active_defaults": { + "name": "idx_recommendation_active_defaults", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"recommendation\".\"default\" = true AND \"recommendation\".\"already_installed\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_recommendation_active_id_property": { + "name": "idx_recommendation_active_id_property", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"recommendation\".\"default\" = true AND \"recommendation\".\"already_installed\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_recommendation_material_id": { + "name": "idx_recommendation_material_id", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recommendation_property_id_property_id_fk": { + "name": "recommendation_property_id_property_id_fk", + "tableFrom": "recommendation", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "recommendation_plan_id_plan_id_fk": { + "name": "recommendation_plan_id_plan_id_fk", + "tableFrom": "recommendation", + "tableTo": "plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recommendation_material_id_material_id_fk": { + "name": "recommendation_material_id_material_id_fk", + "tableFrom": "recommendation", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendation_materials": { + "name": "recommendation_materials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "recommendation_id": { + "name": "recommendation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "depth": { + "name": "depth", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "quantity_unit": { + "name": "quantity_unit", + "type": "unit_quantity", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recommendation_materials_recommendation_id_idx": { + "name": "recommendation_materials_recommendation_id_idx", + "columns": [ + { + "expression": "recommendation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recommendation_materials_recommendation_id_recommendation_id_fk": { + "name": "recommendation_materials_recommendation_id_recommendation_id_fk", + "tableFrom": "recommendation_materials", + "tableTo": "recommendation", + "columnsFrom": [ + "recommendation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recommendation_materials_material_id_material_id_fk": { + "name": "recommendation_materials_material_id_material_id_fk", + "tableFrom": "recommendation_materials", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenario": { + "name": "scenario", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "budget": { + "name": "budget", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "housing_type": { + "name": "housing_type", + "type": "housing_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "goal", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal_value": { + "name": "goal_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ashp_cop": { + "name": "ashp_cop", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 2.8 + }, + "trigger_file_path": { + "name": "trigger_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "already_installed_file_path": { + "name": "already_installed_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patches_file_path": { + "name": "patches_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "non_invasive_recommendations_file_path": { + "name": "non_invasive_recommendations_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exclusions": { + "name": "exclusions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "multi_plan": { + "name": "multi_plan", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "contingency": { + "name": "contingency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "funding": { + "name": "funding", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_pre_retrofit": { + "name": "epc_breakdown_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_post_retrofit": { + "name": "epc_breakdown_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number_of_properties": { + "name": "number_of_properties", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "n_units_to_retrofit": { + "name": "n_units_to_retrofit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_pre_retrofit": { + "name": "co2_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_post_retrofit": { + "name": "co2_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_pre_retrofit": { + "name": "energy_bill_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_post_retrofit": { + "name": "energy_bill_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_pre_retrofit": { + "name": "energy_consumption_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_post_retrofit": { + "name": "energy_consumption_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_improvement_per_unit": { + "name": "valuation_improvement_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_unit": { + "name": "cost_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_co2_saved": { + "name": "cost_per_co2_saved", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_sap_point": { + "name": "cost_per_sap_point", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_return_on_investment": { + "name": "valuation_return_on_investment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "scenario_portfolio_id_portfolio_id_fk": { + "name": "scenario_portfolio_id_portfolio_id_fk", + "tableFrom": "scenario", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_removal_requests": { + "name": "property_removal_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'removal'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "requested_by": { + "name": "requested_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "original_batch": { + "name": "original_batch", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_removal_requests_deal_id": { + "name": "idx_removal_requests_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_removal_requests_portfolio_id": { + "name": "idx_removal_requests_portfolio_id", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_removal_requests_portfolio_id_portfolio_id_fk": { + "name": "property_removal_requests_portfolio_id_portfolio_id_fk", + "tableFrom": "property_removal_requests", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_removal_requests_requested_by_user_id_fk": { + "name": "property_removal_requests_requested_by_user_id_fk", + "tableFrom": "property_removal_requests", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_removal_requests_reviewed_by_user_id_fk": { + "name": "property_removal_requests_reviewed_by_user_id_fk", + "tableFrom": "property_removal_requests", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.solar": { + "name": "solar", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "google_api_response": { + "name": "google_api_response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.solar_scenario": { + "name": "solar_scenario", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "solar_id": { + "name": "solar_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scenario_type": { + "name": "scenario_type", + "type": "scenario_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "number_panels": { + "name": "number_panels", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "array_kwhp": { + "name": "array_kwhp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lifetime_dc_kwh": { + "name": "lifetime_dc_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "yearly_dc_kwh": { + "name": "yearly_dc_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "lifetime_ac_kwh": { + "name": "lifetime_ac_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "yearly_ac_kwh": { + "name": "yearly_ac_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "expected_payback_years": { + "name": "expected_payback_years", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "panelled_roof_area": { + "name": "panelled_roof_area", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "solar_scenario_solar_id_solar_id_fk": { + "name": "solar_scenario_solar_id_solar_id_fk", + "tableFrom": "solar_scenario", + "tableTo": "solar", + "columnsFrom": [ + "solar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.survey_requests": { + "name": "survey_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "survey_type": { + "name": "survey_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "requested_by": { + "name": "requested_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "fulfilled_at": { + "name": "fulfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_survey_requests_deal_id": { + "name": "idx_survey_requests_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_survey_requests_portfolio_id": { + "name": "idx_survey_requests_portfolio_id", + "columns": [ + { + "expression": "portfolio_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "survey_requests_portfolio_id_portfolio_id_fk": { + "name": "survey_requests_portfolio_id_portfolio_id_fk", + "tableFrom": "survey_requests", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "survey_requests_requested_by_user_id_fk": { + "name": "survey_requests_requested_by_user_id_fk", + "tableFrom": "survey_requests", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sub_task": { + "name": "sub_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_started": { + "name": "job_started", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "job_completed": { + "name": "job_completed", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'In Progress'" + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_logs_url": { + "name": "cloud_logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sub_task_task_id_tasks_id_fk": { + "name": "sub_task_task_id_tasks_id_fk", + "tableFrom": "sub_task", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_source": { + "name": "task_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_started": { + "name": "job_started", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "job_completed": { + "name": "job_completed", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'In Progress'" + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_org_id_organisation_id_fk": { + "name": "team_org_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_user_id_user_id_fk": { + "name": "team_members_user_id_user_id_fk", + "tableFrom": "team_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "team_members_team_id_team_id_fk": { + "name": "team_members_team_id_team_id_fk", + "tableFrom": "team_members", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_portfolio_permissions": { + "name": "team_portfolio_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_portfolio_permissions_team_id_team_id_fk": { + "name": "team_portfolio_permissions_team_id_team_id_fk", + "tableFrom": "team_portfolio_permissions", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "team_portfolio_permissions_portfolio_id_portfolio_id_fk": { + "name": "team_portfolio_permissions_portfolio_id_portfolio_id_fk", + "tableFrom": "team_portfolio_permissions", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.uploaded_files": { + "name": "uploaded_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "s3_file_bucket": { + "name": "s3_file_bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_file_key": { + "name": "s3_file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_upload_timestamp": { + "name": "s3_upload_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "landlord_property_id": { + "name": "landlord_property_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hubspot_listing_id": { + "name": "hubspot_listing_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "file_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "file_source": { + "name": "file_source", + "type": "file_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "uploaded_files_uploaded_by_user_id_fk": { + "name": "uploaded_files_uploaded_by_user_id_fk", + "tableFrom": "uploaded_files", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_defined_deal_measures": { + "name": "user_defined_deal_measures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "hubspot_deal_id": { + "name": "hubspot_deal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_name": { + "name": "measure_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "user_defined_deal_measure_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pushed_at": { + "name": "pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmed_in_hubspot_at": { + "name": "confirmed_in_hubspot_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_defined_deal_measures_deal_id": { + "name": "idx_user_defined_deal_measures_deal_id", + "columns": [ + { + "expression": "hubspot_deal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_defined_deal_measures_source": { + "name": "idx_user_defined_deal_measures_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_defined_deal_measures_created_by_user_id_user_id_fk": { + "name": "user_defined_deal_measures_created_by_user_id_user_id_fk", + "tableFrom": "user_defined_deal_measures", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authRateLimits": { + "name": "authRateLimits", + "schema": "", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "authRateLimits_scope_key_pk": { + "name": "authRateLimits_scope_key_pk", + "columns": [ + "scope", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "firstName": { + "name": "firstName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "oauth_id": { + "name": "oauth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login": { + "name": "last_login", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_type": { + "name": "user_type", + "type": "user_profiles_user_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "property_count": { + "name": "property_count", + "type": "user_profiles_property_count", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "goals": { + "name": "goals", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "referral_source": { + "name": "referral_source", + "type": "user_profiles_referral_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "nrla_membership_id": { + "name": "nrla_membership_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "accepted_privacy": { + "name": "accepted_privacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "accepted_privacy_at": { + "name": "accepted_privacy_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "marketing_opt_in": { + "name": "marketing_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "marketing_opt_in_at": { + "name": "marketing_opt_in_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profiles_user_id_user_id_fk": { + "name": "user_profiles_user_id_user_id_fk", + "tableFrom": "user_profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.whlg": { + "name": "whlg", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.aspect_type": { + "name": "aspect_type", + "schema": "public", + "values": [ + "material", + "condition", + "type", + "area", + "configuration", + "presence", + "risk", + "severity", + "location", + "finish", + "insulation", + "pointing", + "spalling", + "lintels", + "cladding", + "category", + "quantity", + "adequacy", + "rating", + "strategy", + "extent", + "distribution", + "structure", + "covering", + "fire_rating", + "external_decoration", + "work_required", + "age_band", + "construction_type", + "classification", + "system" + ] + }, + "public.element_type": { + "name": "element_type", + "schema": "public", + "values": [ + "property", + "property_construction_type", + "property_classification", + "property_age_band", + "storey_count", + "floor_level", + "floor_level_front_door", + "accessible_housing_register", + "asbestos", + "quality_standard", + "ccu", + "passenger_lift", + "stairlift", + "disabled_hoist_tracking", + "disabled_facilities", + "steps_to_front_door", + "roof", + "pitched_roof_covering", + "flat_roof_covering", + "rainwater_goods", + "loft_insulation", + "porch_canopy", + "chimney", + "fascia", + "soffit", + "fascia_soffit_bargeboards", + "gutters", + "store_roof", + "garage_roof", + "garage_and_store_roof", + "external_wall", + "external_noise_insulation", + "primary_wall", + "secondary_wall", + "downpipes", + "external_decoration", + "cladding", + "spandrel_panels", + "garage_walls", + "party_wall_fire_break", + "external_brickwork_pointing", + "internal_downpipes_external_area", + "external_windows", + "communal_windows", + "secondary_glazing", + "store_windows", + "garage_windows", + "garage_and_store_windows", + "external_door", + "front_door", + "rear_door", + "store_door", + "garage_door", + "garage_and_store_door", + "communal_entrance_door", + "main_door", + "block_entrance_door", + "lintel", + "patio_french_door", + "door_entry_handset", + "paths_and_hardstandings", + "parking_areas", + "boundary_walls", + "front_fencing", + "rear_fencing", + "side_fencing", + "rear_gate", + "front_gate", + "gates", + "retaining_walls", + "private_balcony", + "balcony_balustrade", + "outbuildings", + "garage_structure", + "paving", + "roads", + "soil_and_vent", + "solar_thermals", + "drop_kerb", + "outbuilding_overhaul", + "external_structural_defects", + "access_ramp", + "kitchen", + "kitchen_space_layout", + "tenant_installed_kitchen", + "kitchen_extractor_fan", + "bathroom", + "secondary_bathroom", + "secondary_toilet", + "bathroom_extractor_fan", + "additional_wc_or_whb", + "bathroom_remaining_life_source", + "kitchen_remaining_life_source", + "central_heating", + "heating_boiler", + "heating_distribution", + "secondary_heating", + "hot_water_system", + "cold_water_storage", + "heating_system", + "boiler_fuel", + "water_heating", + "programmable_heating", + "community_heating", + "gas_available", + "heat_recovery_units", + "heating_improvements", + "electrical_wiring", + "consumer_unit", + "smoke_detection", + "heat_detection", + "carbon_monoxide_detection", + "fire_door_rating", + "fire_risk_assessment", + "internal_wiring", + "electrics", + "communal_heating", + "communal_boiler", + "communal_electrics", + "communal_fire_alarm", + "communal_emergency_lighting", + "communal_door_entry", + "communal_cctv", + "communal_bin_store", + "communal_bin_store_doors", + "communal_bin_store_walls", + "communal_bin_store_roof", + "communal_refuse_chute", + "communal_floor_covering", + "communal_kitchen", + "communal_bathroom", + "communal_toilets", + "communal_gates", + "communal_lift", + "communal_passenger_lift", + "communal_balcony_walkway", + "communal_entrance", + "communal_internal_decorations", + "communal_internal_floor", + "communal_walkways", + "communal_external_doors", + "communal_stairs", + "communal_aerial", + "communal_aov", + "communal_internal_doors", + "communal_lateral_mains", + "communal_lighting", + "communal_lighting_conductor", + "communal_store_roof", + "communal_store_walls", + "communal_store_doors", + "communal_warden_call_system", + "communal_bms", + "communal_booster_pump", + "communal_dry_riser", + "communal_wet_riser", + "communal_cold_water_storage", + "communal_sprinkler", + "communal_plug_sockets", + "communal_circulation_space", + "ffhh_damp", + "ffhh_hold_and_cold_water", + "ffhh_drainage_lavatories", + "ffhh_neglected", + "ffhh_natural_light", + "ffhh_ventilation", + "ffhh_food_prep_and_washup", + "ffhh_unsafe_layout", + "ffhh_unstable_building", + "hhsrs_damp_and_mould", + "hhsrs_excess_cold", + "hhsrs_excess_heat", + "hhsrs_asbestos_and_mmf", + "hhsrs_biocides", + "hhsrs_carbon_monoxide", + "hhsrs_lead", + "hhsrs_radiation", + "hhsrs_uncombusted_fuel_gas", + "hhsrs_volatile_organic_compounds", + "hhsrs_crowding_and_space", + "hhsrs_entry_by_intruders", + "hhsrs_lighting", + "hhsrs_noise", + "hhsrs_domestic_hygiene_pests_refuse", + "hhsrs_food_safety", + "hhsrs_personal_hygiene_sanitation", + "hhsrs_water_supply", + "hhsrs_falls_associated_with_baths", + "hhsrs_falls_on_level_surfaces", + "hhsrs_falls_on_stairs", + "hhsrs_falls_between_levels", + "hhsrs_electrical_hazards", + "hhsrs_fire", + "hhsrs_flames_hot_surfaces", + "hhsrs_collision_and_entrapment", + "hhsrs_collision_hazards_low_headroom", + "hhsrs_explosions", + "hhsrs_ergonomics", + "hhsrs_structural_collapse", + "hhsrs_amenities" + ] + }, + "public.document_type": { + "name": "document_type", + "schema": "public", + "values": [ + "EPR", + "Condition Report", + "Evidence Report", + "Summary Information", + "Floor Plan", + "Scenario Draft EPC", + "Scenario Site Notes" + ] + }, + "public.scheme": { + "name": "scheme", + "schema": "public", + "values": [ + "eco4", + "gbis", + "whlg", + "none" + ] + }, + "public.inspection_archetype_2": { + "name": "inspection_archetype_2", + "schema": "public", + "values": [ + "detached", + "mid-terrace", + "enclosed mid-terrace", + "end-terrace", + "enclosed end-terrace", + "semi-detached" + ] + }, + "public.inspection_archetype": { + "name": "inspection_archetype", + "schema": "public", + "values": [ + "Bungalow", + "Flat", + "Maisonette", + "House", + "non-domestic" + ] + }, + "public.inspection_borescoped": { + "name": "inspection_borescoped", + "schema": "public", + "values": [ + "yes", + "no", + "refused" + ] + }, + "public.inspections_access_issues": { + "name": "inspections_access_issues", + "schema": "public", + "values": [ + "see notes", + "damp issues", + "foliage on walls", + "bushes against wall", + "trees around/anove property", + "high rise block flats/maisonettes", + "conservatory", + "lean-to", + "garage", + "extension", + "decking", + "shed against wall" + ] + }, + "public.inspections_cladding": { + "name": "inspections_cladding", + "schema": "public", + "values": [ + "none", + "cladded with “sufficient space to fill the wall”", + "cladded with “insufficient space to fill the wall”" + ] + }, + "public.inspections_insulation_material": { + "name": "inspections_insulation_material", + "schema": "public", + "values": [ + "empty 50-90", + "empty 100+", + "empty 30-40", + "empty less than 30", + "loose fibre/wool", + "eps/celo/king", + "fibre batts - with cavity", + "fibre batts - no cavity", + "loose bead", + "glued bead", + "formaldehyde", + "bubble wrap", + "poly chunks" + ] + }, + "public.inspections_rendered": { + "name": "inspections_rendered", + "schema": "public", + "values": [ + "no render", + "rendered with “insufficient” space between dpc and render", + "rendered with “sufficient” space between dpc and render" + ] + }, + "public.inspections_roof_orientation": { + "name": "inspections_roof_orientation", + "schema": "public", + "values": [ + "north", + "east", + "south", + "west", + "north-east", + "north-west", + "south-east", + "south-west", + "n/s split", + "e/w split", + "ne/sw split", + "nw/se split", + "flat roof", + "no roof", + "roof too small", + "already has solar pv" + ] + }, + "public.inspections_tile_hung": { + "name": "inspections_tile_hung", + "schema": "public", + "values": [ + "yes", + "no", + "first floor flats are tile hung" + ] + }, + "public.inspections_wall_construction": { + "name": "inspections_wall_construction", + "schema": "public", + "values": [ + "cavity", + "solid", + "system built", + "timber framed", + "steel framed", + "re-walled cavity", + "mansard pre-fab", + "mansard ewi", + "mansard re-walled" + ] + }, + "public.inspections_wall_insulation": { + "name": "inspections_wall_insulation", + "schema": "public", + "values": [ + "empty cavity", + "filled at build", + "partial", + "retro drilled", + "ewi", + "iwi", + "solid non-cavity", + "system built", + "timber framed", + "steel framed" + ] + }, + "public.built_form_type": { + "name": "built_form_type", + "schema": "public", + "values": [ + "Detached", + "Semi-Detached", + "Mid-Terrace", + "End-Terrace", + "Enclosed Mid-Terrace", + "Enclosed End-Terrace", + "Not Recorded", + "Unknown" + ] + }, + "public.construction_age_band": { + "name": "construction_age_band", + "schema": "public", + "values": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "Unknown" + ] + }, + "public.glazing": { + "name": "glazing", + "schema": "public", + "values": [ + "Single glazing", + "Double glazing, 2002 or later", + "Double glazing, pre-2002", + "Triple glazing, pre-2002", + "Triple glazing, 2002 or later", + "Mixed glazing", + "Secondary glazing", + "Unknown" + ] + }, + "public.main_fuel": { + "name": "main_fuel", + "schema": "public", + "values": [ + "mains gas", + "mains gas (community)", + "electricity", + "electricity (community)", + "LPG (bulk)", + "bottled LPG", + "LPG special condition", + "oil", + "house coal", + "smokeless coal", + "dual fuel (mineral and wood)", + "biomass (community)", + "wood logs", + "Unknown" + ] + }, + "public.main_heating_system": { + "name": "main_heating_system", + "schema": "public", + "values": [ + "Gas boiler, combi", + "Gas boiler, regular", + "Gas CPSU", + "Electric storage heaters, old", + "Electric storage heaters, slimline", + "Electric storage heaters, convector", + "Electric storage heaters, fan", + "Direct-acting electric", + "Electric room heaters", + "Solid fuel room heater, closed", + "Air source heat pump", + "Community heating, boilers", + "Community heating, CHP and boilers", + "Oil room heater, 2000 or later", + "Gas room heater, condensing fire", + "Gas room heater, decorative fuel-effect", + "Gas room heater, flush live-effect", + "Gas room heater, open flue 1980 or later", + "Gas room heater, open flue pre-1980", + "Electric storage heaters, high heat retention", + "Solid fuel room heater, open fire", + "Solid fuel room heater, open fire with back boiler", + "Solid fuel room heater, closed with boiler", + "Electric boiler", + "Electric CPSU", + "Electric underfloor, in concrete slab (off-peak)", + "Electric underfloor, integrated storage and direct-acting", + "Electric underfloor, in screed above insulation", + "Unknown" + ] + }, + "public.override_source": { + "name": "override_source", + "schema": "public", + "values": [ + "classifier", + "user", + "os_places" + ] + }, + "public.property_type": { + "name": "property_type", + "schema": "public", + "values": [ + "House", + "Bungalow", + "Flat", + "Maisonette", + "Park home", + "Unknown" + ] + }, + "public.roof_type": { + "name": "roof_type", + "schema": "public", + "values": [ + "Flat, insulated", + "Flat, insulated (assumed)", + "Flat, limited insulation", + "Flat, limited insulation (assumed)", + "Flat, no insulation", + "Flat, no insulation (assumed)", + "Flat, 12 mm insulation", + "Flat, 25 mm insulation", + "Flat, 50 mm insulation", + "Flat, 75 mm insulation", + "Flat, 100 mm insulation", + "Flat, 125 mm insulation", + "Flat, 150 mm insulation", + "Flat, 175 mm insulation", + "Flat, 200 mm insulation", + "Flat, 225 mm insulation", + "Flat, 250 mm insulation", + "Flat, 270 mm insulation", + "Flat, 300 mm insulation", + "Flat, 350 mm insulation", + "Flat, 400 mm insulation", + "Flat, 400+ mm insulation", + "Pitched, insulated", + "Pitched, insulated (assumed)", + "Pitched, insulated at rafters", + "Pitched, limited insulation", + "Pitched, limited insulation (assumed)", + "Pitched, no insulation", + "Pitched, no insulation (assumed)", + "Pitched, Unknown loft insulation", + "Pitched, 0 mm loft insulation", + "Pitched, 12 mm loft insulation", + "Pitched, 25 mm loft insulation", + "Pitched, 50 mm loft insulation", + "Pitched, 75 mm loft insulation", + "Pitched, 100 mm loft insulation", + "Pitched, 125 mm loft insulation", + "Pitched, 150 mm loft insulation", + "Pitched, 175 mm loft insulation", + "Pitched, 200 mm loft insulation", + "Pitched, 225 mm loft insulation", + "Pitched, 250 mm loft insulation", + "Pitched, 270 mm loft insulation", + "Pitched, 300 mm loft insulation", + "Pitched, 350 mm loft insulation", + "Pitched, 400 mm loft insulation", + "Pitched, 400+ mm loft insulation", + "Roof room(s), insulated", + "Roof room(s), insulated (assumed)", + "Roof room(s), limited insulation", + "Roof room(s), limited insulation (assumed)", + "Roof room(s), no insulation", + "Roof room(s), no insulation (assumed)", + "Roof room(s), ceiling insulated", + "Roof room(s), thatched", + "Roof room(s), thatched with additional insulation", + "Thatched", + "Thatched, with additional insulation", + "(another dwelling above)", + "(same dwelling above)", + "(other premises above)", + "(another premises above)", + "Another Premises Above", + "Unknown" + ] + }, + "public.wall_type": { + "name": "wall_type", + "schema": "public", + "values": [ + "Cavity wall, filled cavity", + "Cavity wall, as built, insulated (assumed)", + "Cavity wall, as built, no insulation (assumed)", + "Cavity wall, as built, partial insulation (assumed)", + "Cavity wall, with internal insulation", + "Cavity wall, with external insulation", + "Cavity wall, filled cavity and internal insulation", + "Cavity wall, filled cavity and external insulation", + "Solid brick, as built, no insulation (assumed)", + "Solid brick, as built, insulated (assumed)", + "Solid brick, as built, partial insulation (assumed)", + "Solid brick, with internal insulation", + "Solid brick, with external insulation", + "Timber frame, as built, no insulation (assumed)", + "Timber frame, as built, insulated (assumed)", + "Timber frame, as built, partial insulation (assumed)", + "Timber frame, with additional insulation", + "Sandstone, as built, no insulation (assumed)", + "Sandstone, as built, insulated (assumed)", + "Sandstone, as built, partial insulation (assumed)", + "Sandstone, with internal insulation", + "Sandstone, with external insulation", + "Granite or whin, as built, no insulation (assumed)", + "Granite or whin, as built, insulated (assumed)", + "Granite or whin, as built, partial insulation (assumed)", + "Granite or whin, with internal insulation", + "Granite or whin, with external insulation", + "System built, as built, no insulation (assumed)", + "System built, as built, insulated (assumed)", + "System built, as built, partial insulation (assumed)", + "System built, with internal insulation", + "System built, with external insulation", + "Park home wall, as built", + "Park home wall, with internal insulation", + "Park home wall, with external insulation", + "Cob, as built", + "Cob, with internal insulation", + "Cob, with external insulation", + "Curtain wall", + "Curtain Wall, as built, no insulation (assumed)", + "Curtain Wall, as built, insulated (assumed)", + "Curtain Wall, filled cavity", + "Curtain Wall, with internal insulation", + "Basement wall", + "Basement wall, as built", + "Unknown" + ] + }, + "public.water_heating": { + "name": "water_heating", + "schema": "public", + "values": [ + "From main system, mains gas", + "From main system, electricity", + "From main system, oil", + "From main system, LPG (bulk)", + "From main system, bottled LPG", + "From main system, house coal", + "Electric immersion, electricity", + "Gas boiler/circulator, mains gas", + "From main system, wood logs", + "From main system, biomass (community)", + "From main system, dual fuel (mineral and wood)", + "From main system, biodiesel (community)", + "Unknown" + ] + }, + "public.cost_unit": { + "name": "cost_unit", + "schema": "public", + "values": [ + "gbp_sq_meter", + "gbp_per_unit", + "gbp_per_m2", + "gbp_per_m" + ] + }, + "public.depth_unit": { + "name": "depth_unit", + "schema": "public", + "values": [ + "mm" + ] + }, + "public.type": { + "name": "type", + "schema": "public", + "values": [ + "suspended_floor_insulation", + "solid_floor_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "cavity_wall_insulation", + "mechanical_ventilation", + "loft_insulation", + "exposed_floor_insulation", + "flat_roof_insulation", + "room_roof_insulation", + "cavity_wall_extraction", + "iwi_wall_demolition", + "iwi_vapour_barrier", + "iwi_redecoration", + "suspended_floor_demolition", + "suspended_floor_redecoration", + "suspended_floor_vapour_barrier", + "solid_floor_demolition", + "solid_floor_preparation", + "solid_floor_vapour_barrier", + "solid_floor_redecoration", + "ewi_wall_demolition", + "ewi_wall_preparation", + "ewi_wall_redecoration", + "low_energy_lighting_installation", + "flat_roof_preparation", + "flat_roof_vapour_barrier", + "flat_roof_waterproofing", + "windows_glazing", + "secondary_glazing", + "double_glazing", + "trickle_vent", + "door_undercut", + "solar_pv", + "solar_battery", + "scaffolding", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "boiler_upgrade", + "roomstat_programmer_trvs", + "time_temperature_zone_control", + "sealing_fireplace" + ] + }, + "public.r_value_unit": { + "name": "r_value_unit", + "schema": "public", + "values": [ + "square_meter_kelvin_per_watt" + ] + }, + "public.size_unit": { + "name": "size_unit", + "schema": "public", + "values": [ + "kWp", + "kW", + "watt", + "storey" + ] + }, + "public.thermal_conductivity_unit": { + "name": "thermal_conductivity_unit", + "schema": "public", + "values": [ + "watt_per_meter_kelvin" + ] + }, + "public.goal": { + "name": "goal", + "schema": "public", + "values": [ + "Valuation Improvement", + "Increasing EPC", + "Reducing CO2 emissions", + "Energy Savings", + "None" + ] + }, + "public.portfolio_capability": { + "name": "portfolio_capability", + "schema": "public", + "values": [ + "approver", + "contractor" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "creator", + "admin", + "read", + "write" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "scoping", + "survey", + "assessment", + "tendering", + "project underway", + "completion; status: on track", + "completion; status: delayed", + "completion; status: at risk", + "completion; status: completed", + "needs review" + ] + }, + "public.override_component": { + "name": "override_component", + "schema": "public", + "values": [ + "wall_type", + "roof_type", + "property_type", + "built_form_type", + "main_fuel", + "glazing", + "construction_age_band", + "water_heating", + "main_heating_system" + ] + }, + "public.energy_element_type": { + "name": "energy_element_type", + "schema": "public", + "values": [ + "roof", + "wall", + "floor", + "main_heating", + "window", + "lighting", + "hot_water", + "secondary_heating", + "main_heating_controls" + ] + }, + "public.epc": { + "name": "epc", + "schema": "public", + "values": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G" + ] + }, + "public.creation_status": { + "name": "creation_status", + "schema": "public", + "values": [ + "LOADING", + "READY", + "ERROR" + ] + }, + "public.rebaseline_reason": { + "name": "rebaseline_reason", + "schema": "public", + "values": [ + "none", + "pre_sap10", + "physical_state_changed", + "both" + ] + }, + "public.housing_type": { + "name": "housing_type", + "schema": "public", + "values": [ + "Private", + "Social" + ] + }, + "public.measure_type": { + "name": "measure_type", + "schema": "public", + "values": [ + "air_source_heat_pump", + "boiler_upgrade", + "high_heat_retention_storage_heaters", + "secondary_heating", + "roomstat_programmer_trvs", + "time_temperature_zone_control", + "cylinder_thermostat", + "cavity_wall_insulation", + "extension_cavity_wall_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "loft_insulation", + "flat_roof_insulation", + "room_roof_insulation", + "solid_floor_insulation", + "suspended_floor_insulation", + "double_glazing", + "secondary_glazing", + "draught_proofing", + "mechanical_ventilation", + "low_energy_lighting", + "solar_pv", + "hot_water_tank_insulation", + "sealing_open_fireplace" + ] + }, + "public.plan_type": { + "name": "plan_type", + "schema": "public", + "values": [ + "solar_eco4", + "solar_hhrsh_eco4", + "empty_cavity_eco", + "partial_cavity_eco", + "extraction_eco" + ] + }, + "public.unit_quantity": { + "name": "unit_quantity", + "schema": "public", + "values": [ + "m2", + "part", + "kwp" + ] + }, + "public.scenario_type": { + "name": "scenario_type", + "schema": "public", + "values": [ + "unit", + "building" + ] + }, + "public.source": { + "name": "source", + "schema": "public", + "values": [ + "portfolio_id", + "hubspot_deal_id", + "property_id" + ] + }, + "public.file_source": { + "name": "file_source", + "schema": "public", + "values": [ + "pas hub", + "sharepoint", + "hubspot", + "ecmk", + "contractor", + "magic_plan", + "coordination_hub", + "audit_generator" + ] + }, + "public.file_type": { + "name": "file_type", + "schema": "public", + "values": [ + "photo_pack", + "site_note", + "rd_sap_site_note", + "pas_2023_ventilation", + "pas_2023_condition", + "pas_significance", + "par_photo_pack", + "pas_2023_property", + "pas_2023_occupancy", + "ecmk_site_note", + "ecmk_rd_sap_site_note", + "ecmk_survey_xml", + "pre_photo", + "mid_photo", + "post_photo", + "loft_hatch_photo", + "dmev_photos", + "door_undercut_photos", + "trickle_vent_photos", + "pre_installation_building_inspection", + "point_of_work_risk_assessment", + "claim_of_compliance", + "mcs_compliance_certificate", + "certificate_of_conformity", + "minor_works_electrical_certificate", + "trustmark_licence_numbers", + "operative_competency", + "ventilation_assessment_checklist", + "anemometer_readings", + "commissioning_records", + "part_f_ventilation_document", + "handover_pack", + "insurance_guarantee", + "workmanship_warranty", + "g98_notification", + "installer_qualifications", + "installer_feedback", + "contractor_other", + "magic_plan_json", + "improvement_option_evaluation", + "medium_term_improvement_plan", + "retrofit_design_doc", + "ventilation_audit", + "other" + ] + }, + "public.user_defined_deal_measure_source": { + "name": "user_defined_deal_measure_source", + "schema": "public", + "values": [ + "instructed", + "pibi_ordered" + ] + }, + "public.user_profiles_property_count": { + "name": "user_profiles_property_count", + "schema": "public", + "values": [ + "1", + "2–5", + "6–20", + "21+", + "1–50", + "51–100", + "101–300", + "301–1000", + "1000+" + ] + }, + "public.user_profiles_referral_source": { + "name": "user_profiles_referral_source", + "schema": "public", + "values": [ + "search", + "social_media", + "NRLA", + "partner", + "word_of_mouth", + "other" + ] + }, + "public.user_profiles_user_type": { + "name": "user_profiles_user_type", + "schema": "public", + "values": [ + "private_landlord", + "private_tenant", + "social_landlord", + "social_tenant", + "homeowner", + "other" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/app/db/migrations/meta/_journal.json b/src/app/db/migrations/meta/_journal.json index ec3676ed..a981644a 100644 --- a/src/app/db/migrations/meta/_journal.json +++ b/src/app/db/migrations/meta/_journal.json @@ -1821,6 +1821,13 @@ "when": 1783345567793, "tag": "0260_natural_enchantress", "breakpoints": true + }, + { + "idx": 261, + "version": "7", + "when": 1783439171770, + "tag": "0261_uprn_corrections", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/db/schema/bulk_upload_uprn_corrections.ts b/src/app/db/schema/bulk_upload_uprn_corrections.ts new file mode 100644 index 00000000..108ee118 --- /dev/null +++ b/src/app/db/schema/bulk_upload_uprn_corrections.ts @@ -0,0 +1,46 @@ +import { pgTable, uuid, text, boolean, timestamp, unique } from "drizzle-orm/pg-core"; + +import { bulkAddressUploads } from "./bulk_address_uploads"; + +// User confirmations of a combiner row's UPRN, captured on the pre-finalise +// "Addresses" review tab (ADR-0057). address2uprn withholds ambiguous / no-UPRN +// matches; the user resolves each flagged row via OS Places here, keyed by the +// combiner CSV's `source_row_id` (there is no `property.id` yet — the finaliser +// creates identities). At dispatchFinaliser these corrections are overlaid onto +// the combiner CSV before the finaliser reads it, so the confirmed UPRN drives +// both the identity insert and property_overrides in one pass — no post-finalise +// backfill (which is why the portfolio "Unmatched" tab is retired). +// +// A row is resolved when it has a numeric `uprn` OR `markedNoUprn = true` (the +// user asserts no UPRN exists — an accepted terminal state; the finaliser +// leaves it as a no-UPRN property). +export const bulkUploadUprnCorrections = pgTable( + "bulk_upload_uprn_corrections", + { + id: uuid("id").defaultRandom().primaryKey(), + uploadId: uuid("upload_id") + .notNull() + .references(() => bulkAddressUploads.id, { onDelete: "cascade" }), + // The combiner CSV join key (synthetic UUID minted at start-address-matching). + sourceRowId: text("source_row_id").notNull(), + // The confirmed UPRN; null when markedNoUprn. + uprn: text("uprn"), + address: text("address"), + postcode: text("postcode"), + // The user asserts this address has no UPRN — resolved without one. + markedNoUprn: boolean("marked_no_uprn").notNull().default(false), + userId: text("user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => ({ + // One correction per combiner row — the confirm action upserts on this. + uploadRowUnique: unique("bulk_upload_uprn_corrections_upload_row_unique").on( + t.uploadId, + t.sourceRowId, + ), + }), +); From 6e30ca311d10a834bdfa9c85ec216d43b30e6677 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 16:00:01 +0000 Subject: [PATCH 22/37] fix(scenarios): exclusion vocabulary = backend MeasureType exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenario 1275's modelling run crashed the engine: its exclusions carried 'room_roof_insulation', which isn't a backend MeasureType — the strict _parse_exclusions raises for every property in the scenario. The authoring vocabulary had seven such tokens (room_roof_insulation, boiler_upgrade, secondary_heating, ventilation, fireplace, hot_water_tank_insulation, cylinder_thermostat). Scenario authoring now uses its own SCENARIO_MEASURES vocabulary — exactly the backend MeasureType set (drifted names corrected, unimplemented measures dropped, missing system_tune_up/_zoned + sloping_ceiling gained), pinned by a test that fails if either side moves alone (the backend's ADR-0056 shared-contract rule, applied app-side). room_roof_insulation leaves DISRUPTIVE_MEASURES until the backend implements the measure; legacy tokens on existing rows still display in constraint summaries. The global measuresDisplayLabels vocabulary (recommendations display, old file-based trigger) is untouched. Co-Authored-By: Claude Fable 5 --- .../scenarios/new/NewScenarioJourney.tsx | 48 ++++++----- src/lib/scenarios/model.test.ts | 53 +++++++++++- src/lib/scenarios/model.ts | 82 +++++++++++++++---- 3 files changed, 139 insertions(+), 44 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx index 3fc372bc..f5208d07 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx @@ -6,11 +6,14 @@ import { useRouter } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio"; import { - measuresDisplayLabels, - measuresList, - type MeasureKey, -} from "@/app/db/schema/recommendations"; -import { DISRUPTIVE_MEASURES, EPC_BANDS, findExactDuplicate, SCENARIO_GOALS } from "@/lib/scenarios/model"; + DISRUPTIVE_MEASURES, + EPC_BANDS, + findExactDuplicate, + SCENARIO_GOALS, + SCENARIO_MEASURES, + scenarioMeasuresList, + type ScenarioMeasureKey, +} from "@/lib/scenarios/model"; import { BandChip, EPC_SOFT, GOAL_DESCRIPTIONS, GOAL_SHORT_LABELS, GoalIcon, bandInk, bandTint, formatMoney } from "../ui"; interface ExistingScenario { @@ -24,20 +27,21 @@ interface ExistingScenario { modelled: boolean; } -const MEASURE_GROUPS: [string, MeasureKey[]][] = [ - ["Insulation", ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "suspended_floor_insulation", "solid_floor_insulation"]], - ["Heating & hot water", ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating", "hot_water_tank_insulation", "cylinder_thermostat"]], - ["Glazing & ventilation", ["double_glazing", "secondary_glazing", "ventilation"]], - ["Renewables & other", ["solar_pv", "low_energy_lighting", "fireplace"]], +// Grouped presentation of SCENARIO_MEASURES (the backend MeasureType vocabulary) +const MEASURE_GROUPS: [string, ScenarioMeasureKey[]][] = [ + ["Insulation", ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", "loft_insulation", "flat_roof_insulation", "sloping_ceiling_insulation", "suspended_floor_insulation", "solid_floor_insulation"]], + ["Heating & hot water", ["gas_boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating_removal", "system_tune_up", "system_tune_up_zoned"]], + ["Glazing & ventilation", ["double_glazing", "secondary_glazing", "mechanical_ventilation"]], + ["Renewables & other", ["solar_pv", "low_energy_lighting"]], ]; -const PRESETS: { label: string; hint: string; excl: MeasureKey[] }[] = [ +const PRESETS: { label: string; hint: string; excl: ScenarioMeasureKey[] }[] = [ { label: "No wet trades", hint: "Rules out plaster-and-screed work: wall and solid-floor insulation", excl: ["internal_wall_insulation", "external_wall_insulation", "solid_floor_insulation"] }, { label: "Fabric only", hint: "Insulation and glazing only — no heating systems or renewables", - excl: ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating", "hot_water_tank_insulation", "cylinder_thermostat", "solar_pv", "low_energy_lighting", "fireplace", "ventilation"] }, + excl: ["gas_boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating_removal", "system_tune_up", "system_tune_up_zoned", "solar_pv", "low_energy_lighting", "mechanical_ventilation"] }, { label: "Low disruption", hint: "Excludes every measure marked as disruptive", - excl: Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[] }, + excl: Object.keys(DISRUPTIVE_MEASURES) as ScenarioMeasureKey[] }, ]; const fieldLabel = @@ -102,7 +106,7 @@ export function NewScenarioJourney({ if (!band) return null; let s = `Reach EPC ${band}`; const ex = [...excluded]; - if (ex.length === 1) s += ` — excl. ${measuresDisplayLabels[ex[0] as MeasureKey]}`; + if (ex.length === 1) s += ` — excl. ${SCENARIO_MEASURES[ex[0] as ScenarioMeasureKey] ?? ex[0]}`; else if (ex.length > 1) s += ` — ${ex.length} exclusions`; if (budgetNumber) s += ` — ${formatMoney(budgetNumber)}/property`; return s; @@ -141,7 +145,7 @@ export function NewScenarioJourney({ setErrors(e); if (Object.keys(e).length) return; } - if (step === 2 && excluded.size === measuresList.length) { + if (step === 2 && excluded.size === scenarioMeasuresList.length) { setErrors({ excl: "You've excluded every measure — the engine would have nothing to work with. Allow at least one." }); return; } @@ -385,7 +389,7 @@ export function NewScenarioJourney({ ))}

- {excluded.size === 0 - ? `All ${measuresList.length} measures allowed` - : `${excluded.size} excluded · ${measuresList.length - excluded.size} allowed`} + ? `All ${scenarioMeasuresList.length} measures allowed` + : `${excluded.size} excluded · ${scenarioMeasuresList.length - excluded.size} allowed`}
{MEASURE_GROUPS.map(([group, keys]) => ( @@ -434,7 +438,7 @@ export function NewScenarioJourney({ {off ? "✕" : "✓"} - {measuresDisplayLabels[k]} + {SCENARIO_MEASURES[k]} {disruptiveReason && !off && ( disruptive @@ -461,14 +465,14 @@ export function NewScenarioJourney({

{(() => { const allowedDisruptive = ( - Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[] + Object.keys(DISRUPTIVE_MEASURES) as ScenarioMeasureKey[] ).filter((k) => !excluded.has(k)); return allowedDisruptive.length > 0 ? (
This scenario still allows{" "} {allowedDisruptive - .map((k) => measuresDisplayLabels[k]) + .map((k) => SCENARIO_MEASURES[k]) .join(", ")} {" "} — disruptive measure{allowedDisruptive.length > 1 ? "s" : ""}{" "} @@ -527,7 +531,7 @@ export function NewScenarioJourney({ key={k} className="rounded-full border border-red-100 bg-white px-3 py-0.5 text-[13px] text-red-800 line-through" > - {measuresDisplayLabels[k as MeasureKey] ?? k} + {SCENARIO_MEASURES[k as ScenarioMeasureKey] ?? k} ))} diff --git a/src/lib/scenarios/model.test.ts b/src/lib/scenarios/model.test.ts index 52b80303..c901b236 100644 --- a/src/lib/scenarios/model.test.ts +++ b/src/lib/scenarios/model.test.ts @@ -1,16 +1,61 @@ import { describe, expect, it } from "vitest"; -import { measuresList } from "@/app/db/schema/recommendations"; import { constraintSummary, deriveScenarioStatus, DISRUPTIVE_MEASURES, findExactDuplicate, parseExclusions, + scenarioMeasuresList, scenarioToInsertRow, serializeExclusions, validateScenarioConfig, } from "./model"; +describe("SCENARIO_MEASURES", () => { + // Mirror of MeasureType in Hestia-Homes/Model domain/modelling/measure_type.py. + // The engine CRASHES on exclusion tokens outside this set at modelling time + // (scenario_table._parse_exclusions is strict), so the two lists must change + // together — the backend's ADR-0056 shared-contract rule, applied here. + const BACKEND_MEASURE_TYPES = [ + "cavity_wall_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "loft_insulation", + "sloping_ceiling_insulation", + "flat_roof_insulation", + "suspended_floor_insulation", + "solid_floor_insulation", + "double_glazing", + "secondary_glazing", + "low_energy_lighting", + "mechanical_ventilation", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "gas_boiler_upgrade", + "system_tune_up", + "system_tune_up_zoned", + "solar_pv", + "secondary_heating_removal", + ]; + + it("offers exactly the backend MeasureType vocabulary", () => { + expect([...scenarioMeasuresList].sort()).toEqual([...BACKEND_MEASURE_TYPES].sort()); + }); + + it("rejects engine-unparseable tokens at authoring time (the 1275 crash)", () => { + const result = validateScenarioConfig({ + name: "Low disruption", + housingType: "Social", + goal: "Increasing EPC", + goalValue: "C", + budgetPerProperty: null, + exclusions: ["room_roof_insulation"], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.exclusions).toMatch(/room_roof_insulation/); + }); +}); + describe("validateScenarioConfig", () => { it("requires a target band when the goal is Increasing EPC", () => { const result = validateScenarioConfig({ @@ -148,7 +193,7 @@ describe("validateScenarioConfig", () => { }); it("rejects excluding every measure — the engine needs at least one option", () => { - const result = validateScenarioConfig({ ...validBase, exclusions: [...measuresList] }); + const result = validateScenarioConfig({ ...validBase, exclusions: [...scenarioMeasuresList] }); expect(result.ok).toBe(false); if (!result.ok) expect(result.errors.exclusions).toBeTruthy(); }); @@ -231,7 +276,7 @@ describe("findExactDuplicate", () => { describe("DISRUPTIVE_MEASURES", () => { it("only marks real measure keys (a typo would silently drop the badge)", () => { for (const key of Object.keys(DISRUPTIVE_MEASURES)) { - expect(measuresList).toContain(key); + expect(scenarioMeasuresList as readonly string[]).toContain(key); } }); }); @@ -249,7 +294,7 @@ describe("display helpers", () => { measures: ["Solid Floor Insulation"], }); // 18 exclusions → say what's allowed instead (in canonical measure order) - const onlyTwo = measuresList.filter( + const onlyTwo = scenarioMeasuresList.filter( (k) => k !== "solar_pv" && k !== "air_source_heat_pump", ); expect(constraintSummary(onlyTwo)).toEqual({ diff --git a/src/lib/scenarios/model.ts b/src/lib/scenarios/model.ts index 5b596420..fe2e3820 100644 --- a/src/lib/scenarios/model.ts +++ b/src/lib/scenarios/model.ts @@ -8,12 +8,51 @@ */ import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio"; -import { - HousingType, - measuresDisplayLabels, - measuresList, - type MeasureKey, -} from "@/app/db/schema/recommendations"; +import { HousingType } from "@/app/db/schema/recommendations"; + +/** + * The measures a Scenario may exclude — EXACTLY the backend's `MeasureType` + * values (Hestia-Homes/Model, domain/modelling/measure_type.py). The engine's + * exclusion parsing is strict: a token outside this set crashes the modelling + * run for every property in the scenario, so this list and the backend enum + * must change together (the backend's ADR-0056 shared-contract rule; pinned + * by a test). Tokens are the backend's; labels are ours. + * + * Deliberately narrower than the legacy `measuresDisplayLabels` vocabulary + * (recommendations display / the old file-based trigger): measures the new + * engine cannot recommend (room-in-roof insulation, fireplace sealing, + * hot-water tank insulation, cylinder thermostats) are not offered — + * excluding the unrecommendable is meaningless, and storing the token is a + * time bomb. Room-in-roof insulation returns here when the backend + * implements it. + */ +export const SCENARIO_MEASURES = { + cavity_wall_insulation: "Cavity Wall Insulation", + external_wall_insulation: "External Wall Insulation", + internal_wall_insulation: "Internal Wall Insulation", + loft_insulation: "Loft Insulation", + sloping_ceiling_insulation: "Sloping Ceiling Insulation", + flat_roof_insulation: "Flat Roof Insulation", + suspended_floor_insulation: "Suspended Floor Insulation", + solid_floor_insulation: "Solid Floor Insulation", + double_glazing: "Double Glazing", + secondary_glazing: "Secondary Glazing", + low_energy_lighting: "Low Energy Lighting", + mechanical_ventilation: "Mechanical Ventilation", + high_heat_retention_storage_heaters: "High Heat Retention Storage Heaters", + air_source_heat_pump: "Air Source Heat Pump", + gas_boiler_upgrade: "Gas Boiler Upgrade", + system_tune_up: "Heating System Tune-up", + system_tune_up_zoned: "Heating System Tune-up (zoned)", + solar_pv: "Solar PV", + secondary_heating_removal: "Secondary Heating Removal", +} as const; + +export type ScenarioMeasureKey = keyof typeof SCENARIO_MEASURES; + +export const scenarioMeasuresList = Object.keys( + SCENARIO_MEASURES, +) as ScenarioMeasureKey[]; export const EPC_BANDS = ["A", "B", "C", "D", "E", "F", "G"] as const; export type EpcBand = (typeof EPC_BANDS)[number]; @@ -78,10 +117,12 @@ export function validateScenarioConfig( // value (any stray input is dropped); the only brake is the budget. const exclusions = [...new Set(input.exclusions)].sort(); - const unknown = exclusions.filter((k) => !(measuresList as string[]).includes(k)); + const unknown = exclusions.filter( + (k) => !(scenarioMeasuresList as string[]).includes(k), + ); if (unknown.length > 0) { errors.exclusions = `Unknown measures: ${unknown.join(", ")}`; - } else if (exclusions.length === measuresList.length) { + } else if (exclusions.length === scenarioMeasuresList.length) { errors.exclusions = "Every measure is excluded — the engine would have nothing to work with. Allow at least one."; } @@ -136,14 +177,16 @@ export function parseExclusions(text: string | null): string[] { * frequently exclude them. The value is the plain-language reason shown to * users. The "Low disruption" quick-start excludes exactly this set. */ -export const DISRUPTIVE_MEASURES: Partial> = { +export const DISRUPTIVE_MEASURES: Partial> = { internal_wall_insulation: "Rooms need clearing and replastering while the work is done", solid_floor_insulation: "Floors are dug up or raised — usually needs the home empty", suspended_floor_insulation: "Floorboards come up in every affected room", - room_roof_insulation: "Loft rooms are stripped back to the rafters", + // room-in-roof insulation belongs here ("loft rooms are stripped back to + // the rafters") but is not yet a backend MeasureType — restore it when the + // backend implements the measure. }; export type ScenarioStatus = "modelled" | "awaiting_modelling"; @@ -166,16 +209,19 @@ export type ConstraintSummary = export function constraintSummary(exclusions: string[]): ConstraintSummary { if (exclusions.length === 0) return { kind: "all" }; const excluded = new Set(exclusions); - const allowed = measuresList.filter((k) => !excluded.has(k)); + const allowed = scenarioMeasuresList.filter((k) => !excluded.has(k)); if (allowed.length <= 3) { - return { kind: "only", measures: allowed.map((k) => measuresDisplayLabels[k]) }; + return { kind: "only", measures: allowed.map((k) => SCENARIO_MEASURES[k]) }; } - return { - kind: "excludes", - measures: measuresList - .filter((k) => excluded.has(k)) - .map((k) => measuresDisplayLabels[k as MeasureKey]), - }; + const known = scenarioMeasuresList + .filter((k) => excluded.has(k)) + .map((k) => SCENARIO_MEASURES[k]); + // Legacy rows may hold tokens outside today's vocabulary — display them + // as themselves rather than dropping them silently. + const legacy = exclusions.filter( + (k) => !(scenarioMeasuresList as string[]).includes(k), + ); + return { kind: "excludes", measures: [...known, ...legacy] }; } /** The stored shape a duplicate check compares against (subset of the scenario row). */ From e960b76afcbab566584ddfffd06c07d8ea60f58f Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 16:06:23 +0000 Subject: [PATCH 23/37] feat(bulk-upload): read flagged combiner rows + save UPRN corrections (ADR-0057) Server module + two routes for the pre-finalise Addresses tab: - getFlaggedAddresses: reads the combiner CSV from combinedOutputS3Uri, returns rows address2uprn could not confidently match (empty UPRN or a non-"matched" status), joined with any saved correction. - upsertUprnCorrection: upserts a confirmed UPRN / no-UPRN per source_row_id. - GET .../address-matches, POST .../address-corrections (thin, auth-guarded). tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[uploadId]/address-corrections/route.ts | 43 +++++ .../[uploadId]/address-matches/route.ts | 22 +++ src/lib/bulkUpload/addressMatches.ts | 177 ++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/address-corrections/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/address-matches/route.ts create mode 100644 src/lib/bulkUpload/addressMatches.ts diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/address-corrections/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/address-corrections/route.ts new file mode 100644 index 00000000..6ae418fa --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/address-corrections/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { z } from "zod"; + +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { upsertUprnCorrection } from "@/lib/bulkUpload/addressMatches"; + +// Confirm one flagged combiner row (ADR-0057): either assign a UPRN chosen from +// OS Places, or assert the address has none. Keyed by `source_row_id` (there is +// no property.id yet — the finaliser creates identities). Upsert semantics, so +// re-confirming overwrites. The confirmation is overlaid onto the combiner CSV +// at dispatchFinaliser. +const AssignSchema = z.object({ + sourceRowId: z.string().min(1), + uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"), + address: z.string().min(1), + postcode: z.string().min(1), +}); +const NoUprnSchema = z.object({ + sourceRowId: z.string().min(1), + markedNoUprn: z.literal(true), +}); +const BodySchema = z.union([NoUprnSchema, AssignSchema]); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + const parsed = BodySchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + { status: 400 }, + ); + } + + await upsertUprnCorrection(uploadId, parsed.data, session.user?.email ?? undefined); + return NextResponse.json({ ok: true }, { status: 200 }); +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/address-matches/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/address-matches/route.ts new file mode 100644 index 00000000..a585aba7 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/address-matches/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; + +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { getFlaggedAddresses } from "@/lib/bulkUpload/addressMatches"; + +// Read-only: the combiner rows address2uprn could not confidently match +// (unmatched / ambiguous_duplicate / invalid_postcode), joined with any saved +// correction. Drives the pre-finalise "Addresses" review tab (ADR-0057); the +// Finalise gate blocks until every flagged row is resolved. +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + const flagged = await getFlaggedAddresses(uploadId); + const unresolved = flagged.filter((f) => !f.resolved).length; + return NextResponse.json({ flagged, unresolved }, { status: 200 }); +} diff --git a/src/lib/bulkUpload/addressMatches.ts b/src/lib/bulkUpload/addressMatches.ts new file mode 100644 index 00000000..198a4c0b --- /dev/null +++ b/src/lib/bulkUpload/addressMatches.ts @@ -0,0 +1,177 @@ +import { eq } from "drizzle-orm"; +import * as XLSX from "xlsx"; + +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { bulkUploadUprnCorrections } from "@/app/db/schema/bulk_upload_uprn_corrections"; +import { createRetrofitDataS3Client } from "@/app/utils/s3"; +import { SOURCE_ROW_ID_COLUMN } from "./s3Keys"; + +// Columns address2uprn writes onto each combiner row (ADR-0057). `status` is the +// per-row outcome; a withheld/ambiguous/no-match row has an empty `uprn`. +const UPRN_COL = "address2uprn_uprn"; +const STATUS_COL = "address2uprn_status"; +const LEXISCORE_COL = "address2uprn_lexiscore"; + +// A combiner row is confidently matched only when address2uprn kept a UPRN AND +// did not flag it. Everything else — unmatched, ambiguous_duplicate, +// invalid_postcode, error, or a legacy row with no status but no UPRN — needs +// the user to resolve it on the Addresses tab before finalise. +function isConfidentlyMatched(row: Record): boolean { + const uprn = (row[UPRN_COL] ?? "").trim(); + const status = (row[STATUS_COL] ?? "").trim(); + if (!uprn) return false; + return status === "" || status === "matched"; +} + +export interface FlaggedAddressRow { + sourceRowId: string; + address: string; + postcode: string; + status: string; + lexiscore: number | null; + // The candidate address2uprn found but did not trust (null when none). + suggestedUprn: string | null; + // The user's confirmation (from bulk_upload_uprn_corrections), if any. + resolvedUprn: string | null; + resolvedAddress: string | null; + markedNoUprn: boolean; + // Resolved = user picked a UPRN OR asserted there is none. + resolved: boolean; +} + +function parseS3Uri(uri: string): { bucket: string; key: string } { + const url = new URL(uri); // s3://bucket/key... + const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname; + if (!url.hostname || !key) throw new Error(`Malformed S3 URI: ${uri}`); + return { bucket: url.hostname, key }; +} + +async function readCombinerRows(s3Uri: string): Promise[]> { + const { bucket, key } = parseS3Uri(s3Uri); + const s3 = createRetrofitDataS3Client(); + const result = await s3.getObject({ Bucket: bucket, Key: key }).promise(); + const body = result.Body?.toString("utf-8"); + if (!body) return []; + const wb = XLSX.read(body, { type: "string" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + // Every cell as a string so a numeric UPRN never arrives as a float. + return XLSX.utils.sheet_to_json>(sheet, { defval: "", raw: false }); +} + +function buildAddress(row: Record): string { + return ["Address 1", "Address 2", "Address 3"] + .map((c) => (row[c] ?? "").trim()) + .filter(Boolean) + .join(", "); +} + +/** + * The combiner rows that need user confirmation, joined with any saved + * correction. Reads the combiner CSV from `combinedOutputS3Uri` (ADR-0057). + * Returns [] until the combiner has run. + */ +export async function getFlaggedAddresses( + uploadId: string, +): Promise { + const [row] = await db + .select({ combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri }) + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)); + if (!row?.combinedOutputS3Uri) return []; + + const [rows, corrections] = await Promise.all([ + readCombinerRows(row.combinedOutputS3Uri), + db + .select() + .from(bulkUploadUprnCorrections) + .where(eq(bulkUploadUprnCorrections.uploadId, uploadId)), + ]); + + const correctionByRow = new Map(corrections.map((c) => [c.sourceRowId, c])); + + return rows + .filter((r) => !isConfidentlyMatched(r)) + .map((r) => { + const sourceRowId = (r[SOURCE_ROW_ID_COLUMN] ?? "").trim(); + const uprn = (r[UPRN_COL] ?? "").trim(); + const rawScore = (r[LEXISCORE_COL] ?? "").trim(); + const lexiscore = rawScore === "" ? null : Number(rawScore); + const c = correctionByRow.get(sourceRowId); + const resolvedUprn = c?.uprn ?? null; + const markedNoUprn = c?.markedNoUprn ?? false; + return { + sourceRowId, + address: buildAddress(r), + postcode: (r["postcode"] ?? "").trim(), + status: (r[STATUS_COL] ?? (uprn ? "matched" : "unmatched")).trim(), + lexiscore: lexiscore !== null && Number.isFinite(lexiscore) ? lexiscore : null, + suggestedUprn: uprn || null, + resolvedUprn, + resolvedAddress: c?.address ?? null, + markedNoUprn, + resolved: resolvedUprn !== null || markedNoUprn, + }; + }); +} + +/** Count of flagged rows the user has not yet resolved — gates Finalise. */ +export async function countUnresolvedFlaggedAddresses(uploadId: string): Promise { + const flagged = await getFlaggedAddresses(uploadId); + return flagged.filter((f) => !f.resolved).length; +} + +export type UprnCorrectionInput = + | { + sourceRowId: string; + uprn: string; + address: string; + postcode: string; + markedNoUprn?: false; + } + | { sourceRowId: string; markedNoUprn: true }; + +/** Upsert one combiner row's confirmation (assign a UPRN, or mark no-UPRN). */ +export async function upsertUprnCorrection( + uploadId: string, + input: UprnCorrectionInput, + userId?: string, +): Promise { + const values = input.markedNoUprn + ? { + uploadId, + sourceRowId: input.sourceRowId, + uprn: null, + address: null, + postcode: null, + markedNoUprn: true as const, + userId: userId ?? null, + } + : { + uploadId, + sourceRowId: input.sourceRowId, + uprn: input.uprn, + address: input.address, + postcode: input.postcode, + markedNoUprn: false as const, + userId: userId ?? null, + }; + + await db + .insert(bulkUploadUprnCorrections) + .values(values) + .onConflictDoUpdate({ + target: [ + bulkUploadUprnCorrections.uploadId, + bulkUploadUprnCorrections.sourceRowId, + ], + set: { + uprn: values.uprn, + address: values.address, + postcode: values.postcode, + markedNoUprn: values.markedNoUprn, + userId: values.userId, + updatedAt: new Date(), + }, + }); +} From 6743cd8baed534f1a553c09b423075c477c781e6 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 16:09:21 +0000 Subject: [PATCH 24/37] feat(bulk-upload): gate finalise on unresolved addresses + overlay corrections (ADR-0057) dispatchFinaliser now: - blocks (before the CAS claim) when any flagged combiner row is still unresolved -> new "unresolved_addresses" outcome; finalize route returns 409. - overlays the user's confirmed UPRNs onto the combiner CSV (buildConfirmedCombinerUri writes a sibling .confirmed.csv) and points the finaliser at it, so a confirmed UPRN drives identity + property_overrides in one pass. No corrections -> original URI unchanged. tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bulk-uploads/[uploadId]/finalize/route.ts | 8 +++ src/lib/bulkUpload/addressMatches.ts | 69 +++++++++++++++++++ src/lib/bulkUpload/server.ts | 23 ++++++- 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts index f326c65c..c5ecc423 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts @@ -35,6 +35,14 @@ export async function POST( return NextResponse.json({ error: "Combiner not finished" }, { status: 409 }); case "missing_task": return NextResponse.json({ error: "Upload has no task to finalise" }, { status: 409 }); + case "unresolved_addresses": + return NextResponse.json( + { + error: `${result.count} address${result.count === 1 ? "" : "es"} still need a UPRN. Resolve them on the Addresses tab before finalising.`, + unresolvedAddresses: result.count, + }, + { status: 409 }, + ); case "wrong_state": return NextResponse.json( { error: `Upload not ready to finalize (state: ${result.current})` }, diff --git a/src/lib/bulkUpload/addressMatches.ts b/src/lib/bulkUpload/addressMatches.ts index 198a4c0b..8d3213ab 100644 --- a/src/lib/bulkUpload/addressMatches.ts +++ b/src/lib/bulkUpload/addressMatches.ts @@ -121,6 +121,75 @@ export async function countUnresolvedFlaggedAddresses(uploadId: string): Promise return flagged.filter((f) => !f.resolved).length; } +const ADDRESS_COL = "address2uprn_address"; +const OVERLAY_STATUS_CONFIRMED = "user_confirmed"; +const OVERLAY_STATUS_NO_UPRN = "user_no_uprn"; + +/** + * Overlay the user's saved corrections onto the combiner CSV and write the + * result to a sibling `…​.confirmed.csv`, returning its S3 URI (ADR-0057). The + * finaliser reads this so a confirmed UPRN drives both the identity insert and + * property_overrides in one pass. No corrections ⇒ the original URI is returned + * unchanged (no rewrite). A `markedNoUprn` row keeps an empty UPRN (accepted + * terminal state — the finaliser leaves it as a no-UPRN property). + */ +export async function buildConfirmedCombinerUri( + uploadId: string, + combinedOutputS3Uri: string, +): Promise { + const corrections = await db + .select() + .from(bulkUploadUprnCorrections) + .where(eq(bulkUploadUprnCorrections.uploadId, uploadId)); + if (corrections.length === 0) return combinedOutputS3Uri; + const byRow = new Map(corrections.map((c) => [c.sourceRowId, c])); + + const { bucket, key } = parseS3Uri(combinedOutputS3Uri); + const s3 = createRetrofitDataS3Client(); + const result = await s3.getObject({ Bucket: bucket, Key: key }).promise(); + const body = result.Body?.toString("utf-8"); + if (!body) return combinedOutputS3Uri; + + const wb = XLSX.read(body, { type: "string" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + // Array-of-arrays so every column + its order is preserved exactly; we patch + // only the address2uprn cells of corrected rows. + const aoa = XLSX.utils.sheet_to_json(sheet, { + header: 1, + defval: "", + raw: false, + blankrows: false, + }); + const headers = (aoa[0] ?? []).map((h) => String(h)); + const iRow = headers.indexOf(SOURCE_ROW_ID_COLUMN); + const iUprn = headers.indexOf(UPRN_COL); + const iStatus = headers.indexOf(STATUS_COL); + const iAddr = headers.indexOf(ADDRESS_COL); + if (iRow < 0 || iUprn < 0) return combinedOutputS3Uri; // not an address2uprn CSV + + for (let r = 1; r < aoa.length; r++) { + const line = aoa[r]; + const srid = String(line[iRow] ?? "").trim(); + const c = byRow.get(srid); + if (!c) continue; + if (c.markedNoUprn) { + line[iUprn] = ""; + if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_NO_UPRN; + } else { + line[iUprn] = c.uprn ?? ""; + if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_CONFIRMED; + if (iAddr >= 0 && c.address) line[iAddr] = c.address; + } + } + + const csv = XLSX.utils.sheet_to_csv(XLSX.utils.aoa_to_sheet(aoa)); + const confirmedKey = `${key.replace(/\.csv$/i, "")}.confirmed.csv`; + await s3 + .putObject({ Bucket: bucket, Key: confirmedKey, Body: csv, ContentType: "text/csv" }) + .promise(); + return `s3://${bucket}/${confirmedKey}`; +} + export type UprnCorrectionInput = | { sourceRowId: string; diff --git a/src/lib/bulkUpload/server.ts b/src/lib/bulkUpload/server.ts index ca303f9b..843156df 100644 --- a/src/lib/bulkUpload/server.ts +++ b/src/lib/bulkUpload/server.ts @@ -20,6 +20,10 @@ import { retrofitDataS3Bucket } from "@/app/utils/s3"; import { SUBTASK_SERVICE } from "./types"; import type { MultiEntrySummary } from "./multiEntry"; import { isPermutation } from "./multiEntry"; +import { + buildConfirmedCombinerUri, + countUnresolvedFlaggedAddresses, +} from "./addressMatches"; const REMAP_ALLOWED: ReadonlySet = new Set([ "ready_for_processing", @@ -677,6 +681,7 @@ export type DispatchFinaliserOutcome = | { kind: "not_yet_combined" } | { kind: "wrong_state"; current: string } | { kind: "missing_task" } + | { kind: "unresolved_addresses"; count: number } | { kind: "trigger_failed"; status: number; message: string }; // Dispatch the async bulk_upload_finaliser (ADR-0005). Replaces the old @@ -705,6 +710,14 @@ export async function dispatchFinaliser(args: { const upload = guarded.upload; if (!upload.taskId) return { kind: "missing_task" }; + // ADR-0057: every combiner row address2uprn could not confidently match must + // be resolved on the Addresses tab (a chosen UPRN or an explicit no-UPRN) + // before finalise — otherwise a withheld row would land as an unintended + // no-UPRN property. Gate before the CAS claim so a blocked upload stays in + // awaiting_review. + const unresolved = await countUnresolvedFlaggedAddresses(args.uploadId); + if (unresolved > 0) return { kind: "unresolved_addresses", count: unresolved }; + // CAS: atomically claim the dispatch. Only the request that flips // awaiting_review → finalising proceeds; a concurrent one updates 0 rows. const claimed = await db @@ -748,10 +761,18 @@ export async function dispatchFinaliser(args: { ? `s3://${retrofitDataS3Bucket()}/${classifierCsvKey(upload.portfolioId, args.uploadId)}` : null; + // Overlay the user's confirmed UPRNs onto the combiner CSV and point the + // finaliser at the corrected copy (ADR-0057). No corrections ⇒ the original + // URI is returned unchanged. + const confirmedS3Uri = await buildConfirmedCombinerUri( + args.uploadId, + upload.combinedOutputS3Uri!, + ); + const payload = { task_id: upload.taskId, sub_task_id: subTask.id, - s3_uri: upload.combinedOutputS3Uri, + s3_uri: confirmedS3Uri, portfolio_id: Number(upload.portfolioId), bulk_upload_id: args.uploadId, classifier_s3_uri: classifierS3Uri, From ee22764e0651a239e18f5f4ab98054494134131e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 16:10:56 +0000 Subject: [PATCH 25/37] feat(bulk-upload): client hooks for address matches + corrections (ADR-0057) useAddressMatches (flagged rows + unresolved count) and useSaveUprnCorrection (assign UPRN / mark no-UPRN), matching the existing TanStack Query v4 conventions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/bulkUpload/client.ts | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/lib/bulkUpload/client.ts b/src/lib/bulkUpload/client.ts index 3f3a34e9..939d0f01 100644 --- a/src/lib/bulkUpload/client.ts +++ b/src/lib/bulkUpload/client.ts @@ -150,6 +150,74 @@ export function useSampleClassifications( }); } +// One combiner row that address2uprn could not confidently match (ADR-0057), +// plus any saved correction. Mirrors the server FlaggedAddressRow. +export interface FlaggedAddress { + sourceRowId: string; + address: string; + postcode: string; + status: string; + lexiscore: number | null; + suggestedUprn: string | null; + resolvedUprn: string | null; + resolvedAddress: string | null; + markedNoUprn: boolean; + resolved: boolean; +} + +export interface AddressMatchesView { + flagged: FlaggedAddress[]; + unresolved: number; +} + +export function useAddressMatches( + portfolioId: string, + uploadId: string, + enabled: boolean, +) { + return useQuery({ + queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"], + enabled, + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-matches`, + ); + if (!res.ok) throw await parseError(res, "Failed to load address matches."); + const body = await res.json(); + return { + flagged: (body.flagged ?? []) as FlaggedAddress[], + unresolved: Number(body.unresolved ?? 0), + }; + }, + }); +} + +export type SaveCorrectionInput = + | { sourceRowId: string; uprn: string; address: string; postcode: string } + | { sourceRowId: string; markedNoUprn: true }; + +export function useSaveUprnCorrection(portfolioId: string, uploadId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input) => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-corrections`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (!res.ok) throw await parseError(res, "Failed to save address."); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"], + }); + }, + }); +} + export function useConfirmMultiEntryOrdering(portfolioId: string, uploadId: string) { const queryClient = useQueryClient(); return useMutation }>({ From cfa181f3c705b73de8353b292d9274baf3b2872f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 16:18:44 +0000 Subject: [PATCH 26/37] =?UTF-8?q?feat(home):=20portfolio=20home=20redesign?= =?UTF-8?q?=20=E2=80=94=20folder=20rail,=20starred=20view,=20grid/list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client page driven by useQuery on /api/me/portfolio-home with optimistic mutations (star, move-to-folder, folder create/rename/delete, drag or menu-based reorder). TDD'd view-model in portfolioHomeView.ts (status pill mapping, search over display language, sort incl needs-attention, money/date formatting, optimistic config upsert); groupPortfoliosForHome generalised over id type so the client re-groups with wire-format string ids. List view is a semantic table; replaces CardTiles/Card/AddNewCard (NewPortfolioModal retained). Co-Authored-By: Claude Fable 5 --- src/app/api/me/portfolio-home/route.ts | 16 +- src/app/components/home/AddNewCard.tsx | 49 --- src/app/components/home/Card.tsx | 68 ---- src/app/components/home/CardTiles.tsx | 35 -- src/app/components/home/FolderRail.tsx | 253 ++++++++++++ src/app/components/home/PortfolioCard.tsx | 297 ++++++++++++++ src/app/components/home/PortfolioHome.tsx | 458 ++++++++++++++++++++++ src/app/home/page.tsx | 19 +- src/app/lib/portfolioHomeView.test.ts | 122 ++++++ src/app/lib/portfolioHomeView.ts | 191 +++++++++ src/app/lib/portfolioUserConfig.ts | 25 +- 11 files changed, 1339 insertions(+), 194 deletions(-) delete mode 100644 src/app/components/home/AddNewCard.tsx delete mode 100644 src/app/components/home/Card.tsx delete mode 100644 src/app/components/home/CardTiles.tsx create mode 100644 src/app/components/home/FolderRail.tsx create mode 100644 src/app/components/home/PortfolioCard.tsx create mode 100644 src/app/components/home/PortfolioHome.tsx create mode 100644 src/app/lib/portfolioHomeView.test.ts create mode 100644 src/app/lib/portfolioHomeView.ts diff --git a/src/app/api/me/portfolio-home/route.ts b/src/app/api/me/portfolio-home/route.ts index 4fb62a7c..3f4095ef 100644 --- a/src/app/api/me/portfolio-home/route.ts +++ b/src/app/api/me/portfolio-home/route.ts @@ -8,10 +8,9 @@ import { userPortfolioConfig, userPortfolioFolders, } from "@/app/db/schema/user_portfolio_config"; -import { groupPortfoliosForHome } from "@/app/lib/portfolioUserConfig"; -// Everything the home page renders: the caller's portfolios grouped by their -// personal folders, with starred float and a most-recently-starred list. +// Everything the home page renders, flat: the client groups via +// groupPortfoliosForHome so optimistic mutations can re-group locally. export async function GET() { try { const session = await getServerSession(AuthOptions); @@ -40,16 +39,7 @@ export async function GET() { .where(eq(userPortfolioFolders.userId, userId)), ]); - const starredIds = new Set( - configs.filter((c) => c.starredAt !== null).map((c) => c.portfolioId), - ); - const grouped = groupPortfoliosForHome({ portfolios, configs, folders }); - const body = { - folders: grouped.folders, - unfiled: grouped.unfiled, - starred: grouped.starred, - starredIds: [...starredIds], - }; + const body = { portfolios, configs, folders }; // bigint isn't JSON-serialisable; ids cross the wire as strings. return new NextResponse( diff --git a/src/app/components/home/AddNewCard.tsx b/src/app/components/home/AddNewCard.tsx deleted file mode 100644 index 6eb76ba3..00000000 --- a/src/app/components/home/AddNewCard.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useState } from "react"; -import PlusIcon from "../PlusIcon"; -import NewPortfolioModal from "./NewPortfolioModal"; - -const styles = { - wrapper: - "group bg-brandblue hover:bg-hoverblue shadow-xl hover:shadow-none cursor-pointer rounded-3xl flex flex-col items-center justify-center aspect-square", - header: "relative mt-2 mx-2 w-full", - imageWrapper: - "relative rounded-2xl overflow-hidden flex justify-center items-center", - wrapperAnime: "transition-all duration-500 ease-in-out", - image: "object-cover w-8/12 h-8/12 mx-auto fill-white", - textWrapper: "w-full flex justify-center items-center pt-6", - text: "pb-6 font-medium leading-none text-base tracking-wider text-gray-400", -}; - -const AddNewCard = () => { - const title = "New Portfolio"; - const [isModalOpen, setModalIsOpen] = useState(false); - - const openModal = () => { - setModalIsOpen(true); - }; - - return ( - -
-
-
-
-
- - -
-
-
-
-

{`${title}`}

-
-
-
-
- ); -}; - -export default AddNewCard; diff --git a/src/app/components/home/Card.tsx b/src/app/components/home/Card.tsx deleted file mode 100644 index be2c72c5..00000000 --- a/src/app/components/home/Card.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react"; -import StatusBadge from "@/app/components/StatusBadge"; -import { useRouter } from "next/navigation"; -import { PortfolioStatus } from "@/app/db/schema/portfolio"; -import { formatNumber } from "@/app/utils"; -import Image from "next/image"; - - -const styles = { - wrapper: - "group py-2 px-3 active:bg-brandmidblue font-medium leading-none text-base tracking-wider text-gray-400 hover:text-white-300 bg-white hover:bg-hoverblue shadow-2xl hover:shadow-none cursor-pointer aspect-square rounded-3xl flex flex-col items-center justify-center", - header: "relative mt-2 w-full border-brandblue", - budgetWrapper: "min-h-7 pr-4 flex justify-end w-full text-right max-h-16 my-auto text-gray-700 group-hover:text-white transition-all duration-500 ease-in-out relative", - imageWrapper: "rounded-2xl overflow-hidden flex justify-center items-center", - wrapperAnime: "transition-all duration-500 ease-in-out", - image: "object-cover mx-auto", - textWrapper: "pb-3 w-full px-4 flex justify-center items-center max-h-16 my-auto text-gray-700 text-center group-hover:text-white transition-all duration-500 ease-in-out", -}; - -interface CardProps { - id: bigint; - title: string; - image: string; - budget: number | null; - status: (typeof PortfolioStatus)[number]; -}; - -const Card = ({ id, title, image, budget, status }: CardProps) => { - const router = useRouter(); - - function handleClick() { - router.push(`/portfolio/${id}`); - } - - const budgetFormatted = budget ? "£" + formatNumber(budget) : ""; - - return ( -
-
-
-
{budgetFormatted}
-
- -
-
-
-

{`${title}`}

-
-
-
- -
-
-
-
- ); -}; - -export default Card; diff --git a/src/app/components/home/CardTiles.tsx b/src/app/components/home/CardTiles.tsx deleted file mode 100644 index 467045e9..00000000 --- a/src/app/components/home/CardTiles.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import Card from "./Card"; -import AddNewCard from "./AddNewCard"; -import type { Portfolio } from "@/app/db/schema/portfolio"; - -type PortfoliosType = Array; - -export default function CardTiles({ - Portfolios, -}: { - Portfolios: PortfoliosType; -}) { - return ( -
-
- - {Portfolios.map((portfolio, index) => { - const image_idx = index % 5; - return ( - - ); - })} -
-
- ); -} diff --git a/src/app/components/home/FolderRail.tsx b/src/app/components/home/FolderRail.tsx new file mode 100644 index 00000000..e215e846 --- /dev/null +++ b/src/app/components/home/FolderRail.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useState } from "react"; +import { MoreVertical, Plus, Star } from "lucide-react"; +import type { FolderWire } from "@/app/lib/portfolioHomeView"; + +export type RailView = "all" | "starred" | { folderId: string }; + +export type FolderRailProps = { + folders: FolderWire[]; // already ordered by position + view: RailView; + totalCount: number; + starredCount: number; + attentionTotal: number; + countOf: (folderId: string) => number; + attentionOf: (folderId: string) => number; + onSelect: (view: RailView) => void; + onReorder: (orderedIds: string[]) => void; + onCreate: (name: string) => void; + onRename: (folderId: string, name: string) => void; + onDelete: (folderId: string) => void; +}; + +function AttentionBadge({ count }: { count: number }) { + if (!count) return null; + return ( + + ⚠ {count} + + ); +} + +function railItemClasses(active: boolean) { + return `flex w-full min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm ${ + active ? "bg-[#e4e9f1] font-semibold" : "hover:bg-[#e9edf3]" + }`; +} + +export default function FolderRail(props: FolderRailProps) { + const [dragIndex, setDragIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + const [creating, setCreating] = useState(false); + const [editingId, setEditingId] = useState(null); + + const isFolderView = (folderId: string) => + typeof props.view === "object" && props.view.folderId === folderId; + + const commitDrop = () => { + if ( + dragIndex !== null && + dropIndex !== null && + dropIndex !== dragIndex + ) { + const ids = props.folders.map((f) => f.id); + const [moved] = ids.splice(dragIndex, 1); + ids.splice(dropIndex, 0, moved); + props.onReorder(ids); + } + setDragIndex(null); + setDropIndex(null); + }; + + const nameForm = ( + defaultValue: string, + onSubmit: (name: string) => void, + onDone: () => void, + ) => ( +
{ + e.preventDefault(); + const name = new FormData(e.currentTarget).get("name"); + if (typeof name === "string" && name.trim()) onSubmit(name.trim()); + onDone(); + }} + > + e.key === "Escape" && onDone()} + className="w-full rounded-md border border-brandmidblue px-2 py-1 text-sm focus:outline-none" + /> +
+ ); + + return ( + + ); +} diff --git a/src/app/components/home/PortfolioCard.tsx b/src/app/components/home/PortfolioCard.tsx new file mode 100644 index 00000000..35df8e81 --- /dev/null +++ b/src/app/components/home/PortfolioCard.tsx @@ -0,0 +1,297 @@ +"use client"; + +import Link from "next/link"; +import { MoreVertical, Star } from "lucide-react"; +import { + formatMoney, + formatUpdatedAgo, + statusDisplay, + type FolderWire, + type PortfolioWire, + type StatusTone, +} from "@/app/lib/portfolioHomeView"; + +const PILL_TONES: Record = { + early: "bg-slate-100 text-slate-600", + tender: "bg-indigo-100 text-indigo-800", + underway: "bg-blue-100 text-blue-800", + ontrack: "bg-green-100 text-green-800", + delayed: "bg-amber-100 text-amber-800", + atrisk: "bg-red-100 text-red-800", + done: "bg-emerald-100 text-emerald-900", + review: "bg-yellow-100 text-yellow-800", +}; + +export type CardActions = { + isStarred: (portfolioId: string) => boolean; + folderIdOf: (portfolioId: string) => string | null; + onToggleStar: (portfolio: PortfolioWire) => void; + onMoveToFolder: (portfolio: PortfolioWire, folderId: string | null) => void; + folders: FolderWire[]; +}; + +function StatusPill({ status }: { status: string }) { + const display = statusDisplay(status); + return ( + + + {display.label} + + ); +} + +function StarButton({ + portfolio, + actions, +}: { + portfolio: PortfolioWire; + actions: CardActions; +}) { + const starred = actions.isStarred(portfolio.id); + return ( + + ); +} + +function CardMenu({ + portfolio, + actions, + openUpward, +}: { + portfolio: PortfolioWire; + actions: CardActions; + openUpward?: boolean; +}) { + const currentFolderId = actions.folderIdOf(portfolio.id); + return ( +
+ + + +
+
+ Move to folder +
+ {actions.folders.map((folder) => ( + + ))} + +
+
+ ); +} + +function BudgetLine({ portfolio }: { portfolio: PortfolioWire }) { + const { budget, cost } = portfolio; + if (!budget) { + return
No budget set
; + } + const pct = cost != null ? Math.round((100 * cost) / budget) : null; + const over = cost != null && cost > budget; + return ( +
+
+ {cost != null ? ( + <> + + + {formatMoney(cost)} + {" "} + of {formatMoney(budget)}{" "} + budget + + + {pct}%{over ? " over" : ""} + + + ) : ( + <> + + Budget{" "} + + {formatMoney(budget)} + + + no spend yet + + )} +
+
+
+
+
+ ); +} + +export function PortfolioGridCard({ + portfolio, + actions, + folderName, +}: { + portfolio: PortfolioWire; + actions: CardActions; + folderName?: string | null; +}) { + return ( +
+
+ + {portfolio.name} + + +
+
+ + + {portfolio.goal === "None" ? "No goal set" : portfolio.goal} + + {portfolio.numberOfProperties != null && ( + + + {portfolio.numberOfProperties.toLocaleString()} + {" "} + properties + + )} +
+ +
+ + {folderName ? `${folderName} · ` : ""} + Updated {formatUpdatedAgo(portfolio.updatedAt, new Date())} + + +
+
+ ); +} + +export function PortfolioTable({ + portfolios, + actions, + folderNameOf, +}: { + portfolios: PortfolioWire[]; + actions: CardActions; + folderNameOf: (portfolioId: string) => string | null; +}) { + return ( +
+ + + + + + + + + + + + + {portfolios.map((portfolio) => ( + + + + + + + + + + + ))} + +
+ PortfolioStatusGoalPropertiesBudgetUpdated +
+ + + + {portfolio.name} + + + + + {portfolio.goal === "None" ? "—" : portfolio.goal} + + {portfolio.numberOfProperties?.toLocaleString() ?? "—"} + + + + {folderNameOf(portfolio.id) + ? `${folderNameOf(portfolio.id)} · ` + : ""} + {formatUpdatedAgo(portfolio.updatedAt, new Date())} + + +
+
+ ); +} diff --git a/src/app/components/home/PortfolioHome.tsx b/src/app/components/home/PortfolioHome.tsx new file mode 100644 index 00000000..14758f1a --- /dev/null +++ b/src/app/components/home/PortfolioHome.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { LayoutGrid, List, Plus, Search } from "lucide-react"; +import { + matchesSearch, + sortPortfolios, + statusDisplay, + upsertLocalConfig, + type HomeData, + type HomeSort, + type PortfolioWire, +} from "@/app/lib/portfolioHomeView"; +import FolderRail, { type RailView } from "./FolderRail"; +import { + PortfolioGridCard, + PortfolioTable, + type CardActions, +} from "./PortfolioCard"; +import NewPortfolioModal from "./NewPortfolioModal"; + +const HOME_KEY = ["portfolio-home"]; + +async function fetchHome(): Promise { + const res = await fetch("/api/me/portfolio-home"); + if (!res.ok) throw new Error("Failed to load portfolios"); + return res.json(); +} + +async function send(url: string, method: string, body?: unknown) { + const res = await fetch(url, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const payload = await res.json().catch(() => null); + throw new Error(payload?.error ?? "Request failed"); + } +} + +// Shared optimistic-mutation plumbing: apply the local update immediately, +// roll back on error, reconcile with the server afterwards. +function useOptimisticHomeMutation( + mutationFn: (vars: V) => Promise, + localUpdate: (home: HomeData, vars: V) => HomeData, + announceError: (message: string) => void, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onMutate: async (vars: V) => { + await queryClient.cancelQueries(HOME_KEY); + const previous = queryClient.getQueryData(HOME_KEY); + if (previous) { + queryClient.setQueryData(HOME_KEY, localUpdate(previous, vars)); + } + return { previous }; + }, + onError: (_err: unknown, _vars: V, context?: { previous?: HomeData }) => { + if (context?.previous) { + queryClient.setQueryData(HOME_KEY, context.previous); + } + announceError("Something went wrong — change undone"); + }, + onSettled: () => queryClient.invalidateQueries(HOME_KEY), + }); +} + +export default function PortfolioHome() { + const queryClient = useQueryClient(); + const { data, isLoading, isError, refetch } = useQuery(HOME_KEY, fetchHome); + + const [view, setView] = useState("all"); + const [display, setDisplay] = useState<"grid" | "list">("grid"); + const [sort, setSort] = useState("updated"); + const [query, setQuery] = useState(""); + const [announcement, setAnnouncement] = useState(""); + const [newPortfolioOpen, setNewPortfolioOpen] = useState(false); + + const configMutation = useOptimisticHomeMutation( + (vars: { + portfolioId: string; + change: { starred?: boolean; folderId?: string | null }; + }) => + send(`/api/me/portfolios/${vars.portfolioId}/config`, "PATCH", vars.change), + (home, vars) => ({ + ...home, + configs: upsertLocalConfig(home.configs, vars.portfolioId, { + ...(vars.change.starred !== undefined && { + starredAt: vars.change.starred ? new Date().toISOString() : null, + }), + ...(vars.change.folderId !== undefined && { + folderId: vars.change.folderId, + }), + }), + }), + setAnnouncement, + ); + + const reorderMutation = useOptimisticHomeMutation( + (orderedIds: string[]) => + send("/api/me/folders/reorder", "PATCH", { orderedIds }), + (home, orderedIds) => ({ + ...home, + folders: home.folders.map((f) => ({ + ...f, + position: orderedIds.indexOf(f.id), + })), + }), + setAnnouncement, + ); + + const renameMutation = useOptimisticHomeMutation( + (vars: { folderId: string; name: string }) => + send(`/api/me/folders/${vars.folderId}`, "PATCH", { name: vars.name }), + (home, vars) => ({ + ...home, + folders: home.folders.map((f) => + f.id === vars.folderId ? { ...f, name: vars.name } : f, + ), + }), + setAnnouncement, + ); + + const deleteMutation = useOptimisticHomeMutation( + (folderId: string) => send(`/api/me/folders/${folderId}`, "DELETE"), + (home, folderId) => ({ + ...home, + folders: home.folders.filter((f) => f.id !== folderId), + configs: home.configs.map((c) => + c.folderId === folderId ? { ...c, folderId: null } : c, + ), + }), + setAnnouncement, + ); + + const createMutation = useMutation({ + mutationFn: (name: string) => send("/api/me/folders", "POST", { name }), + onSettled: () => queryClient.invalidateQueries(HOME_KEY), + }); + + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 6 }, (_, i) => ( +
+ ))} +
+
+ ); + } + if (isError || !data) { + return ( +
+ Couldn’t load your portfolios.{" "} + +
+ ); + } + + // ---- derived render state (all plain derivation, no memoization needed + // at this scale) ---- + const configOf = new Map(data.configs.map((c) => [c.portfolioId, c])); + const folderById = new Map(data.folders.map((f) => [f.id, f])); + const orderedFolders = [...data.folders].sort( + (a, b) => a.position - b.position, + ); + + const isStarred = (id: string) => configOf.get(id)?.starredAt != null; + const folderIdOf = (id: string) => configOf.get(id)?.folderId ?? null; + const folderNameOf = (id: string) => { + const folderId = folderIdOf(id); + return folderId ? (folderById.get(folderId)?.name ?? null) : null; + }; + const needsAttention = (p: PortfolioWire) => + statusDisplay(p.status).needsAttention; + + const inView = (p: PortfolioWire) => + view === "all" + ? true + : view === "starred" + ? isStarred(p.id) + : folderIdOf(p.id) === view.folderId; + + const scope = data.portfolios.filter(inView); + const filtered = scope.filter((p) => + matchesSearch({ ...p, folderName: folderNameOf(p.id) }, query), + ); + let rows = sortPortfolios(filtered, sort); + if (view === "starred") { + rows = [...rows].sort( + (a, b) => + new Date(configOf.get(b.id)!.starredAt!).getTime() - + new Date(configOf.get(a.id)!.starredAt!).getTime(), + ); + } else { + rows = [...rows].sort( + (a, b) => Number(isStarred(b.id)) - Number(isStarred(a.id)), + ); + } + + const viewTitle = + view === "all" + ? "All portfolios" + : view === "starred" + ? "Starred" + : (folderById.get(view.folderId)?.name ?? "Folder"); + const attentionCount = rows.filter(needsAttention).length; + const propertiesSum = rows.reduce( + (sum, p) => sum + (p.numberOfProperties ?? 0), + 0, + ); + + const cardActions: CardActions = { + isStarred, + folderIdOf, + folders: orderedFolders, + onToggleStar: (p) => { + const starred = !isStarred(p.id); + configMutation.mutate({ portfolioId: p.id, change: { starred } }); + setAnnouncement(`${starred ? "Starred" : "Unstarred"} ${p.name}`); + }, + onMoveToFolder: (p, folderId) => { + configMutation.mutate({ portfolioId: p.id, change: { folderId } }); + setAnnouncement( + folderId + ? `Moved ${p.name} to ${folderById.get(folderId)?.name}` + : `Removed ${p.name} from its folder`, + ); + }, + }; + + return ( +
+
+

+ Portfolios +

+
+ + +
+ {( + [ + ["grid", LayoutGrid, "Grid view"], + ["list", List, "List view"], + ] as const + ).map(([value, Icon, label]) => ( + + ))} +
+ +
+
+ +
+ isStarred(p.id)).length} + attentionTotal={data.portfolios.filter(needsAttention).length} + countOf={(folderId) => + data.portfolios.filter((p) => folderIdOf(p.id) === folderId).length + } + attentionOf={(folderId) => + data.portfolios.filter( + (p) => folderIdOf(p.id) === folderId && needsAttention(p), + ).length + } + onSelect={setView} + onReorder={(orderedIds) => { + reorderMutation.mutate(orderedIds); + setAnnouncement("Folder order saved"); + }} + onCreate={(name) => createMutation.mutate(name)} + onRename={(folderId, name) => + renameMutation.mutate({ folderId, name }) + } + onDelete={(folderId) => { + deleteMutation.mutate(folderId); + setAnnouncement("Folder removed — its portfolios are unfiled"); + }} + /> + +
+
+

+ {viewTitle} +

+
+ {query.trim() ? ( + <> + Showing {rows.length} of{" "} + {scope.length} for “ + {query.trim()}” + + ) : ( + <> + {rows.length} portfolios + {propertiesSum > 0 && ( + <> + {" "} + · + {propertiesSum.toLocaleString()} + {" "} + properties + + )} + {attentionCount > 0 && ( + <> + {" "} + ·{" "} + + {attentionCount} need attention + + + )} + + )} +
+
+ + {rows.length === 0 ? ( + query.trim() ? ( +
+ No portfolios in {viewTitle} match “ + {query.trim()}”.{" "} + +
+ ) : ( +
+ {view === "starred" ? ( + <> + + Nothing starred yet. + {" "} + Star a portfolio to keep your day-to-day work one click + away. + + ) : view === "all" ? ( + <> + + No portfolios yet. + {" "} + Create your first portfolio to get started. + + ) : ( + <> + + This folder is empty. + {" "} + Move portfolios here with the ⋯ menu on any card. + + )} +
+ ) + ) : display === "grid" ? ( +
+ {rows.map((portfolio) => ( + + ))} +
+ ) : ( + null + } + /> + )} +
+
+ +
+ {announcement} +
+ +
+ ); +} diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index f82a9829..85ce4c92 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -1,30 +1,15 @@ -import CardTiles from "../components/home/CardTiles"; -import { getPortfolios } from "./utils"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; +import PortfolioHome from "../components/home/PortfolioHome"; const Home = async () => { const user = await getServerSession(AuthOptions); if (!user?.user) { - console.error("User not found"); redirect("/"); } - const portfolios = await getPortfolios(user.user.dbId); - return ( - <> -
-

- {" "} - Your Portfolios{" "} -

-
-
- -
- - ); + return ; }; export default Home; diff --git a/src/app/lib/portfolioHomeView.test.ts b/src/app/lib/portfolioHomeView.test.ts new file mode 100644 index 00000000..2a4801ed --- /dev/null +++ b/src/app/lib/portfolioHomeView.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { PortfolioStatus } from "@/app/db/schema/portfolio"; +import { + formatMoney, + formatUpdatedAgo, + matchesSearch, + sortPortfolios, + statusDisplay, + upsertLocalConfig, +} from "./portfolioHomeView"; + +describe("statusDisplay", () => { + it("maps lifecycle enum values to clean pill labels", () => { + expect(statusDisplay("scoping")).toEqual({ + label: "Scoping", + tone: "early", + needsAttention: false, + }); + expect(statusDisplay("completion; status: at risk")).toEqual({ + label: "At risk", + tone: "atrisk", + needsAttention: true, + }); + expect(statusDisplay("needs review")).toEqual({ + label: "Needs review", + tone: "review", + needsAttention: true, + }); + }); + + it("covers every status the schema allows", () => { + for (const status of PortfolioStatus) { + const display = statusDisplay(status); + expect(display.label, status).toBeTruthy(); + expect(display.label).not.toContain(";"); + } + }); +}); + +describe("formatMoney", () => { + it("renders millions with one decimal and thousands as whole k", () => { + expect(formatMoney(6500000)).toBe("£6.5M"); + expect(formatMoney(4000000)).toBe("£4M"); + expect(formatMoney(745000)).toBe("£745k"); + expect(formatMoney(0)).toBe("£0"); + }); +}); + +describe("matchesSearch", () => { + const lambeth = { + name: "Lambeth Regeneration", + status: "completion; status: at risk", + goal: "Increasing EPC", + folderName: "SHDF Wave 3", + }; + + it("matches on name, display status, goal, and folder name, case-insensitively", () => { + expect(matchesSearch(lambeth, "lambeth")).toBe(true); + expect(matchesSearch(lambeth, "at risk")).toBe(true); + expect(matchesSearch(lambeth, "epc")).toBe(true); + expect(matchesSearch(lambeth, "shdf")).toBe(true); + expect(matchesSearch(lambeth, "peabody")).toBe(false); + }); + + it("matches everything on an empty query and unfoldered portfolios don't match folder terms", () => { + expect(matchesSearch(lambeth, " ")).toBe(true); + expect( + matchesSearch({ ...lambeth, folderName: null }, "shdf"), + ).toBe(false); + }); +}); + +describe("sortPortfolios", () => { + const rows = [ + { name: "B", status: "scoping", numberOfProperties: 10, updatedAt: new Date("2026-07-01") }, + { name: "A", status: "completion; status: delayed", numberOfProperties: 30, updatedAt: new Date("2026-07-06") }, + { name: "C", status: "completion; status: at risk", numberOfProperties: 20, updatedAt: new Date("2026-07-03") }, + ]; + const names = (sorted: typeof rows) => sorted.map((r) => r.name); + + it("orders by the chosen key without mutating the input", () => { + expect(names(sortPortfolios(rows, "updated"))).toEqual(["A", "C", "B"]); + expect(names(sortPortfolios(rows, "attention"))).toEqual(["C", "A", "B"]); + expect(names(sortPortfolios(rows, "name"))).toEqual(["A", "B", "C"]); + expect(names(sortPortfolios(rows, "props"))).toEqual(["A", "C", "B"]); + expect(names(rows)).toEqual(["B", "A", "C"]); + }); +}); + +describe("upsertLocalConfig", () => { + it("creates a config on first star, like the lazy DB upsert", () => { + const next = upsertLocalConfig([], "7", { starredAt: "2026-07-07T12:00:00Z" }); + + expect(next).toEqual([ + { portfolioId: "7", folderId: null, starredAt: "2026-07-07T12:00:00Z" }, + ]); + }); + + it("changing one field preserves the other", () => { + const configs = [ + { portfolioId: "7", folderId: "10", starredAt: "2026-07-01T00:00:00Z" }, + ]; + + expect(upsertLocalConfig(configs, "7", { starredAt: null })).toEqual([ + { portfolioId: "7", folderId: "10", starredAt: null }, + ]); + expect(upsertLocalConfig(configs, "7", { folderId: null })).toEqual([ + { portfolioId: "7", folderId: null, starredAt: "2026-07-01T00:00:00Z" }, + ]); + }); +}); + +describe("formatUpdatedAgo", () => { + const now = new Date("2026-07-07T12:00:00Z"); + + it("reads naturally at every distance", () => { + expect(formatUpdatedAgo("2026-07-07T09:00:00Z", now)).toBe("today"); + expect(formatUpdatedAgo("2026-07-06T09:00:00Z", now)).toBe("yesterday"); + expect(formatUpdatedAgo("2026-07-02T12:00:00Z", now)).toBe("5 days ago"); + expect(formatUpdatedAgo("2026-05-01T00:00:00Z", now)).toBe("2 months ago"); + }); +}); diff --git a/src/app/lib/portfolioHomeView.ts b/src/app/lib/portfolioHomeView.ts new file mode 100644 index 00000000..512ca9e0 --- /dev/null +++ b/src/app/lib/portfolioHomeView.ts @@ -0,0 +1,191 @@ +// View-model logic for the portfolio home page: pure functions the client +// components derive their render state from. Kept free of React and drizzle +// so it tests in node and survives refactors on either side. + +export type StatusTone = + | "early" + | "tender" + | "underway" + | "ontrack" + | "delayed" + | "atrisk" + | "done" + | "review"; + +export type StatusDisplay = { + label: string; + tone: StatusTone; + needsAttention: boolean; +}; + +// The lifecycle enum's raw values ("completion; status: at risk") are storage +// strings, not UI copy; this is the single place they become pill text. +const STATUS_DISPLAY: Record = { + scoping: { label: "Scoping", tone: "early", needsAttention: false }, + survey: { label: "Survey", tone: "early", needsAttention: false }, + assessment: { label: "Assessment", tone: "early", needsAttention: false }, + tendering: { label: "Tendering", tone: "tender", needsAttention: false }, + "project underway": { + label: "Underway", + tone: "underway", + needsAttention: false, + }, + "completion; status: on track": { + label: "On track", + tone: "ontrack", + needsAttention: false, + }, + "completion; status: delayed": { + label: "Delayed", + tone: "delayed", + needsAttention: true, + }, + "completion; status: at risk": { + label: "At risk", + tone: "atrisk", + needsAttention: true, + }, + "completion; status: completed": { + label: "Completed", + tone: "done", + needsAttention: false, + }, + "needs review": { label: "Needs review", tone: "review", needsAttention: true }, +}; + +const UNKNOWN_STATUS: StatusDisplay = { + label: "Unknown", + tone: "early", + needsAttention: false, +}; + +export function statusDisplay(status: string): StatusDisplay { + return STATUS_DISPLAY[status] ?? UNKNOWN_STATUS; +} + +export type HomeSort = "updated" | "attention" | "name" | "props"; + +// Wire-format rows (ids and dates as strings — see the portfolio-home +// route's bigint serialisation). +export type LocalConfig = { + portfolioId: string; + folderId: string | null; + starredAt: string | null; +}; + +export type PortfolioWire = { + id: string; + name: string; + status: string; + goal: string; + budget: number | null; + cost: number | null; + numberOfProperties: number | null; + updatedAt: string; +}; + +export type FolderWire = { id: string; name: string; position: number }; + +export type HomeData = { + portfolios: PortfolioWire[]; + configs: LocalConfig[]; + folders: FolderWire[]; +}; + +// Client-side mirror of the lazy DB upsert, used for optimistic cache +// updates: no row yet means "all defaults", and a change touches only the +// fields it names. +export function upsertLocalConfig( + configs: LocalConfig[], + portfolioId: string, + change: Partial>, +): LocalConfig[] { + const existing = configs.find((c) => c.portfolioId === portfolioId); + if (!existing) { + return [ + ...configs, + { portfolioId, folderId: null, starredAt: null, ...change }, + ]; + } + return configs.map((c) => + c.portfolioId === portfolioId ? { ...c, ...change } : c, + ); +} + +// Severity for "needs attention first": worst state leads, healthy and +// finished work trails. +const ATTENTION_RANK: Record = { + atrisk: 0, + delayed: 1, + review: 2, + underway: 3, + tender: 4, + early: 5, + ontrack: 6, + done: 7, +}; + +export function sortPortfolios< + P extends { + name: string; + status: string; + numberOfProperties: number | null; + updatedAt: Date | string; + }, +>(portfolios: P[], sort: HomeSort): P[] { + const by: Record number> = { + updated: (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + attention: (a, b) => + ATTENTION_RANK[statusDisplay(a.status).tone] - + ATTENTION_RANK[statusDisplay(b.status).tone], + name: (a, b) => a.name.localeCompare(b.name), + props: (a, b) => (b.numberOfProperties ?? 0) - (a.numberOfProperties ?? 0), + }; + return [...portfolios].sort(by[sort]); +} + +// Users search in display language ("at risk"), never in enum storage +// strings, so matching goes through statusDisplay. +export function matchesSearch( + portfolio: { + name: string; + status: string; + goal: string; + folderName: string | null; + }, + query: string, +): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return [ + portfolio.name, + statusDisplay(portfolio.status).label, + portfolio.goal, + portfolio.folderName ?? "", + ] + .join(" ") + .toLowerCase() + .includes(q); +} + +export function formatUpdatedAgo(updatedAt: string, now: Date): string { + const days = Math.floor( + (now.getTime() - new Date(updatedAt).getTime()) / 86_400_000, + ); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 28) return `${days} days ago`; + const months = Math.round(days / 30); + return months === 1 ? "1 month ago" : `${months} months ago`; +} + +export function formatMoney(amount: number): string { + if (amount >= 1_000_000) { + return "£" + (amount / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; + } + if (amount >= 1_000) { + return "£" + Math.round(amount / 1_000) + "k"; + } + return "£" + Math.round(amount); +} diff --git a/src/app/lib/portfolioUserConfig.ts b/src/app/lib/portfolioUserConfig.ts index 37203e64..74194da8 100644 --- a/src/app/lib/portfolioUserConfig.ts +++ b/src/app/lib/portfolioUserConfig.ts @@ -60,33 +60,34 @@ export function planFolderDeletion({ }; } -type ConfigRow = { - portfolioId: bigint; - folderId: bigint | null; - starredAt: Date | null; +type ConfigRow = { + portfolioId: Id; + folderId: Id | null; + starredAt: Date | string | null; }; -type FolderRow = { id: bigint; name: string; position: number }; +type FolderRow = { id: Id; name: string; position: number }; // Assembles the home page's render model from the three per-user reads. -// Generic over the portfolio payload so it doesn't care which columns the -// page selects. -export function groupPortfoliosForHome

({ +// Generic over the portfolio payload and the id type: the server groups with +// bigints; the client re-groups optimistically with wire-format string ids. +export function groupPortfoliosForHome({ portfolios, configs, folders, }: { portfolios: P[]; - configs: ConfigRow[]; - folders: FolderRow[]; + configs: Array>; + folders: Array>; }): { - folders: Array<{ id: bigint; name: string; portfolios: P[] }>; + folders: Array<{ id: Id; name: string; portfolios: P[] }>; unfiled: P[]; starred: P[]; } { const configByPortfolio = new Map(configs.map((c) => [c.portfolioId, c])); const folderOf = (p: P) => configByPortfolio.get(p.id)?.folderId ?? null; const starredAt = (p: P) => configByPortfolio.get(p.id)?.starredAt ?? null; + const starredTime = (p: P) => new Date(starredAt(p)!).getTime(); // Starred float above unstarred; original relative order is otherwise kept. const floatStarred = (group: P[]) => @@ -104,6 +105,6 @@ export function groupPortfoliosForHome

({ unfiled: floatStarred(portfolios.filter((p) => folderOf(p) === null)), starred: portfolios .filter((p) => starredAt(p) !== null) - .sort((a, b) => starredAt(b)!.getTime() - starredAt(a)!.getTime()), + .sort((a, b) => starredTime(b) - starredTime(a)), }; } From 8b99e7029c942e9bf458132ad58c96904b5a925e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 16:19:18 +0000 Subject: [PATCH 27/37] feat(bulk-upload): two-tab review with pre-finalise Addresses/UPRN confirmation (ADR-0057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At awaiting_review, when address2uprn flagged rows, OnboardingProgress now shows two tabs: Classification (existing verify/unknown/ordering panels) and Addresses. The Addresses tab (AddressesPanel) lists each flagged combiner row with the OS Places matcher (reused search → residential candidates) and a "No UPRN" action, saving corrections via useSaveUprnCorrection. Finalise is gated on unresolvedAddresses === 0 with a matching disabled reason; the tab carries a badge of the unresolved count. tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bulk-upload/[uploadId]/AddressesPanel.tsx | 320 ++++++++++++++++++ .../[uploadId]/OnboardingProgress.tsx | 149 +++++--- 2 files changed, 428 insertions(+), 41 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx new file mode 100644 index 00000000..6f99ab1e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useState } from "react"; +import { MagnifyingGlassIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { describeCandidate } from "@/lib/postcodeSearch/model"; +import { searchAddresses, type SearchCandidate } from "@/lib/properties/client"; +import { useSaveUprnCorrection, type FlaggedAddress } from "@/lib/bulkUpload/client"; + +// The pre-finalise "Addresses" review tab (ADR-0057). Lists the combiner rows +// address2uprn could not confidently match; the user resolves each via Ordnance +// Survey (residential candidates only → a guaranteed valid UPRN) or marks it as +// having no UPRN. Confirmations are saved as corrections (keyed by +// source_row_id) and overlaid onto the combiner CSV at finalise. +export default function AddressesPanel({ + flagged, + portfolioId, + uploadId, +}: { + flagged: FlaggedAddress[]; + portfolioId: string; + uploadId: string; +}) { + return ( +

+
+

+ Addresses that need a UPRN +

+

+ We couldn't confidently match these to Ordnance Survey. Pick the + right address — or mark it as having no UPRN — so onboarding creates the + correct property. +

+
+ + {flagged.length === 0 ? ( +

Nothing to resolve.

+ ) : ( +
    + {flagged.map((row) => ( + + ))} +
+ )} +
+ ); +} + +function statusLabel(status: string): string { + switch (status) { + case "ambiguous_duplicate": + return "Ambiguous match"; + case "unmatched": + return "No match"; + case "invalid_postcode": + return "Invalid postcode"; + case "error": + return "Match error"; + default: + return status; + } +} + +function AddressRow({ + row, + portfolioId, + uploadId, +}: { + row: FlaggedAddress; + portfolioId: string; + uploadId: string; +}) { + const save = useSaveUprnCorrection(portfolioId, uploadId); + const [open, setOpen] = useState(false); + const [address, setAddress] = useState(row.address ?? ""); + const [postcode, setPostcode] = useState(row.postcode ?? ""); + // null = not searched yet; [] = searched, no residential matches. + const [residential, setResidential] = useState(null); + const [selected, setSelected] = useState(null); + const [source, setSource] = useState<"cache" | "live" | null>(null); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + + async function runSearch() { + setSearching(true); + setSearchError(null); + setResidential(null); + setSelected(null); + try { + const result = await searchAddresses(portfolioId, postcode); + // Residential only — we never assign a non-residential UPRN. + setResidential(result.candidates.filter((c) => c.selectable)); + setSource(result.source); + } catch (e) { + setSearchError(e instanceof Error ? e.message : "Search failed."); + } finally { + setSearching(false); + } + } + + function onAssign() { + if (!selected) return; + save.mutate( + { + sourceRowId: row.sourceRowId, + uprn: selected.uprn, + address: selected.address, + postcode: selected.postcode, + }, + { onSuccess: () => setOpen(false) }, + ); + } + + function onNoUprn() { + save.mutate({ sourceRowId: row.sourceRowId, markedNoUprn: true }); + } + + return ( +
  • +
    +

    + {row.address || "(no address)"} +

    +

    + {row.postcode || "no postcode"} + {row.lexiscore != null ? ` · score ${row.lexiscore.toFixed(2)}` : ""} + {" · "} + {statusLabel(row.status)} +

    + {row.resolved && ( +

    + + {row.markedNoUprn + ? "Marked: no UPRN" + : `UPRN ${row.resolvedUprn}${row.resolvedAddress ? ` · ${row.resolvedAddress}` : ""}`} +

    + )} +
    + +
    + {row.resolved ? ( + + ) : ( + <> + + + + )} +
    + + + + + Match to a UPRN + + Confirm the postcode and pick the matching address. Only residential + addresses from Ordnance Survey are shown, so the UPRN is always valid. + + + +
    +
    + + setAddress(e.target.value)} + placeholder="e.g. 20 Brenchley Road" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
    +
    +
    + + setPostcode(e.target.value)} + placeholder="e.g. BR5 2TD" + onKeyDown={(e) => { + if (e.key === "Enter" && postcode.trim() && !searching) + runSearch(); + }} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
    + +
    + + {searchError &&

    {searchError}

    } + + {residential && ( +
    +
    +

    + Residential addresses +

    + {source === "cache" && ( + + from cache + + )} +
    +
    + {residential.length === 0 ? ( +

    + No residential addresses found for that postcode. +

    + ) : ( +
      + {residential.map((c) => { + const { label } = describeCandidate(c); + const isSelected = selected?.uprn === c.uprn; + return ( +
    • + +
    • + ); + })} +
    + )} +
    +
    + )} +
    + + + {save.error && ( +

    + {save.error.message} +

    + )} + + +
    +
    +
    +
  • + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index f5dc7cd0..fe48a58b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { + useAddressMatches, useBulkUploadProgress, useConfirmMultiEntryOrdering, useConfirmVerification, @@ -14,6 +15,7 @@ import { useSampleClassifications, type SampleClassifications, } from "@/lib/bulkUpload/client"; +import AddressesPanel from "./AddressesPanel"; import { partLabel, isPermutation, @@ -82,6 +84,9 @@ export default function OnboardingProgress({ }); const combine = useRequestCombine(portfolioId, uploadId); const finalize = useFinalize(portfolioId, uploadId); + const [reviewTab, setReviewTab] = useState<"classification" | "addresses">( + "classification", + ); // Read-only classifications for the multi-entry sample (issue #298). Fetched // only once a sample exists at awaiting_review. Hook stays above the early @@ -90,6 +95,13 @@ export default function OnboardingProgress({ progress.data?.upload.status === "awaiting_review" && !!progress.data.upload.multiEntrySummary?.sample; const classifications = useSampleClassifications(portfolioId, uploadId, sampleReady); + // Combiner rows address2uprn could not confidently match (ADR-0057). Fetched + // once at awaiting_review; drives the Addresses tab + the Finalise gate. + const addressMatches = useAddressMatches( + portfolioId, + uploadId, + progress.data?.upload.status === "awaiting_review", + ); if (progress.isError) return null; if (!progress.data) { @@ -152,11 +164,17 @@ export default function OnboardingProgress({ (n, descriptions) => n + descriptions.length, 0, ); + // Flagged addresses (no confident UPRN) must all be resolved before finalise, + // else a withheld row lands as an unintended no-UPRN property (ADR-0057). + const flaggedCount = addressMatches.data?.flagged.length ?? 0; + const unresolvedAddresses = addressMatches.data?.unresolved ?? 0; + const showAddressTab = isAwaitingReview && flaggedCount > 0; const canFinalize = isAwaitingReview && (!needsVerify || verifyAck) && (!needsOrdering || orderingConfirmed) && - unknownTotal === 0; + unknownTotal === 0 && + unresolvedAddresses === 0; return (
    @@ -224,47 +242,94 @@ export default function OnboardingProgress({ )}
    - {needsVerify && sample && ( - + {/* Two-tab review at awaiting_review: classification checks | address/UPRN + confirmation (ADR-0057). The Addresses tab only appears when there are + flagged rows; otherwise the classification panels render as before. */} + {showAddressTab && ( +
    + + +
    )} - {isAwaitingReview && unknownTotal > 0 && ( - - )} - - {needsOrdering && orderingSamples.length > 0 && ( -
    - {orderingSamples.map(([count, orderSample], i) => ( - + {needsVerify && sample && ( + 1 - ? `Part group ${i + 1}` - : undefined - } + verified={verifyAck} + stepLabel={showStepNumbers ? "Step 1" : undefined} portfolioId={portfolioId} uploadId={uploadId} /> - ))} -
    + )} + + {isAwaitingReview && unknownTotal > 0 && ( + + )} + + {needsOrdering && orderingSamples.length > 0 && ( +
    + {orderingSamples.map(([count, orderSample], i) => ( + 1 + ? `Part group ${i + 1}` + : undefined + } + portfolioId={portfolioId} + uploadId={uploadId} + /> + ))} +
    + )} + + )} + + {showAddressTab && reviewTab === "addresses" && ( + )} {(canRunCombiner || isAwaitingReview) && ( @@ -284,11 +349,13 @@ export default function OnboardingProgress({ isPending={finalize.isPending} disabled={!canFinalize} disabledReason={ - unknownTotal > 0 - ? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first` - : needsVerify && !verifyAck - ? "Verify the classification first" - : "Confirm the building-part order first" + unresolvedAddresses > 0 + ? `Resolve ${unresolvedAddresses} address${unresolvedAddresses === 1 ? "" : "es"} on the Addresses tab first` + : unknownTotal > 0 + ? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first` + : needsVerify && !verifyAck + ? "Verify the classification first" + : "Confirm the building-part order first" } onClick={() => finalize.mutate(undefined, { onSuccess: () => router.refresh() }) From 3f649755b470cb2e6c80ab8f18ac93e62a77e92d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 16:29:19 +0000 Subject: [PATCH 28/37] feat(portfolio): retire the Unmatched tab; surface low_score (ADR-0057 Phase 4/5) Phase 4: the portfolio landing page now renders the property table directly. UPRN matching is confirmed during onboarding, so properties arrive matched and the post-onboarding "Unmatched" tab is no longer needed (tab components left in place pending a legacy-no-UPRN reconciliation follow-up). Phase 5: AddressesPanel labels the new "low_score" status "Low-confidence match". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bulk-upload/[uploadId]/AddressesPanel.tsx | 2 ++ src/app/portfolio/[slug]/(portfolio)/page.tsx | 20 ++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx index 6f99ab1e..cbff4d3f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx @@ -63,6 +63,8 @@ function statusLabel(status: string): string { switch (status) { case "ambiguous_duplicate": return "Ambiguous match"; + case "low_score": + return "Low-confidence match"; case "unmatched": return "No match"; case "invalid_postcode": diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index 24bd0aba..c3305755 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -1,7 +1,4 @@ import PropertyTable from "../components/PropertyTable"; -import UnmatchedProperties from "../components/UnmatchedProperties"; -import PortfolioTabs from "../components/PortfolioTabs"; -import { getUnmatchedProperties } from "@/lib/properties/unmatched"; export const revalidate = 60; @@ -14,15 +11,10 @@ export default async function Page(props: { const params = await props.params; const portfolioId = params.slug; - const unmatched = await getUnmatchedProperties(portfolioId); - - return ( - } - needsAttention={ - - } - /> - ); + // UPRN matching is now confirmed during bulk-upload onboarding (ADR-0057), so + // properties arrive already matched — the portfolio-level "Unmatched" tab is + // retired. The tab components (PortfolioTabs / UnmatchedProperties / + // getUnmatchedProperties) are left in place for now, pending a follow-up that + // reconciles legacy no-UPRN properties onboarded before this feature. + return ; } From 1940f02cf36af4cc8544bcd07024a861ff7fa7ac Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 16:32:19 +0000 Subject: [PATCH 29/37] feat(home): delete-folder confirm, view preference cookie, show-more batching Folder removal now confirms with copy stating portfolios are unfiled, not deleted. Grid/list preference persists in a cookie read by the server component, so the chosen view renders on first paint. Long lists render the first 24 rows with a show-more button; the reveal count resets when the view or search changes, and starred float keeps pinned work on the first screen. Co-Authored-By: Claude Fable 5 --- src/app/components/home/PortfolioHome.tsx | 67 ++++++++++++++++++++--- src/app/home/page.tsx | 9 ++- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/app/components/home/PortfolioHome.tsx b/src/app/components/home/PortfolioHome.tsx index 14758f1a..feb883e9 100644 --- a/src/app/components/home/PortfolioHome.tsx +++ b/src/app/components/home/PortfolioHome.tsx @@ -22,6 +22,10 @@ import NewPortfolioModal from "./NewPortfolioModal"; const HOME_KEY = ["portfolio-home"]; +// "Show more" batch size: bounds the initial wall without pagination chrome. +// Divisible by 2 and 3 so grid rows stay full at every breakpoint. +const PAGE_SIZE = 24; + async function fetchHome(): Promise { const res = await fetch("/api/me/portfolio-home"); if (!res.ok) throw new Error("Failed to load portfolios"); @@ -68,14 +72,32 @@ function useOptimisticHomeMutation( }); } -export default function PortfolioHome() { +export default function PortfolioHome({ + initialDisplay = "grid", +}: { + initialDisplay?: "grid" | "list"; +}) { const queryClient = useQueryClient(); const { data, isLoading, isError, refetch } = useQuery(HOME_KEY, fetchHome); const [view, setView] = useState("all"); - const [display, setDisplay] = useState<"grid" | "list">("grid"); + const [display, setDisplay] = useState<"grid" | "list">(initialDisplay); + const chooseDisplay = (value: "grid" | "list") => { + setDisplay(value); + document.cookie = `home-display=${value}; path=/; max-age=31536000; samesite=lax`; + }; const [sort, setSort] = useState("updated"); const [query, setQuery] = useState(""); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + // Changing what's listed resets how much of it is revealed. + const chooseView = (next: RailView) => { + setView(next); + setVisibleCount(PAGE_SIZE); + }; + const chooseQuery = (next: string) => { + setQuery(next); + setVisibleCount(PAGE_SIZE); + }; const [announcement, setAnnouncement] = useState(""); const [newPortfolioOpen, setNewPortfolioOpen] = useState(false); @@ -209,6 +231,9 @@ export default function PortfolioHome() { ); } + const visibleRows = rows.slice(0, visibleCount); + const hiddenCount = rows.length - visibleRows.length; + const viewTitle = view === "all" ? "All portfolios" @@ -256,7 +281,7 @@ export default function PortfolioHome() { setQuery(e.target.value)} + onChange={(e) => chooseQuery(e.target.value)} placeholder="Search portfolios" aria-label="Search portfolios by name, status, goal or folder" className="w-60 rounded-lg border border-[#c3ccd8] bg-white py-2 pl-8 pr-3 text-sm" @@ -290,7 +315,7 @@ export default function PortfolioHome() { aria-pressed={display === value} aria-label={label} title={label} - onClick={() => setDisplay(value)} + onClick={() => chooseDisplay(value)} className={`grid place-items-center px-2.5 py-2 ${ display === value ? "bg-brandblue text-white" @@ -326,7 +351,7 @@ export default function PortfolioHome() { (p) => folderIdOf(p.id) === folderId && needsAttention(p), ).length } - onSelect={setView} + onSelect={chooseView} onReorder={(orderedIds) => { reorderMutation.mutate(orderedIds); setAnnouncement("Folder order saved"); @@ -336,6 +361,17 @@ export default function PortfolioHome() { renameMutation.mutate({ folderId, name }) } onDelete={(folderId) => { + const folder = folderById.get(folderId); + const count = data.portfolios.filter( + (p) => folderIdOf(p.id) === folderId, + ).length; + const inside = + count === 0 + ? "It is empty." + : `Its ${count} portfolio${count === 1 ? "" : "s"} will be unfiled, not deleted.`; + if (!window.confirm(`Remove folder “${folder?.name}”? ${inside}`)) { + return; + } deleteMutation.mutate(folderId); setAnnouncement("Folder removed — its portfolios are unfiled"); }} @@ -386,7 +422,7 @@ export default function PortfolioHome() { {query.trim()}”.{" "} + + Showing {visibleRows.length}{" "} + of {rows.length} + +
    + )}
    diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index 85ce4c92..4111b06d 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -1,5 +1,6 @@ import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { getServerSession } from "next-auth"; +import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import PortfolioHome from "../components/home/PortfolioHome"; @@ -10,6 +11,12 @@ const Home = async () => { redirect("/"); } - return ; + // Device-level display preference travels as a cookie so the server renders + // the chosen view on first paint (no client-side flicker). + const cookieStore = await cookies(); + const initialDisplay = + cookieStore.get("home-display")?.value === "list" ? "list" : "grid"; + + return ; }; export default Home; From ade33195cb9bb66b27cad9571407eb4003faf4c1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 17:01:16 +0000 Subject: [PATCH 30/37] fix(home): inline delete confirm, visible mutation errors, unclipped list menu Impeccable critique (25/40) fixes: window.confirm replaced by a two-step confirm inside the folder menu (states the unfile consequence, red confirm takes focus, Escape cancels); mutation failures now render a dismissible notice as well as the live region, and folder-create errors are no longer swallowed; list-view card menu uses fixed positioning measured on open so the table's overflow container can't clip it; Escape closes card/folder menus with focus returned. Also: search placeholder and rail-count contrast to >=4.5:1, folder-menu and New-folder hit targets to >=24px, th scope=col, decorative ticks aria-hidden, rename input regains a visible focus state. Co-Authored-By: Claude Fable 5 --- ...7-07T16-54-50Z__src-app-components-home.md | 67 +++++++ src/app/components/home/FolderRail.tsx | 163 ++++++++++++------ src/app/components/home/PortfolioCard.tsx | 67 +++++-- src/app/components/home/PortfolioHome.tsx | 42 +++-- 4 files changed, 249 insertions(+), 90 deletions(-) create mode 100644 .impeccable/critique/2026-07-07T16-54-50Z__src-app-components-home.md diff --git a/.impeccable/critique/2026-07-07T16-54-50Z__src-app-components-home.md b/.impeccable/critique/2026-07-07T16-54-50Z__src-app-components-home.md new file mode 100644 index 00000000..fd3e6334 --- /dev/null +++ b/.impeccable/critique/2026-07-07T16-54-50Z__src-app-components-home.md @@ -0,0 +1,67 @@ +--- +target: implemented portfolio home page +total_score: 25 +p0_count: 0 +p1_count: 4 +timestamp: 2026-07-07T16-54-50Z +slug: src-app-components-home +--- +# Critique: Implemented portfolio home page (feature/home-portfolio-redesign) + +Method: dual-agent (A: design review · B: detector/mechanical evidence). Target: src/app/components/home/* + src/app/home/page.tsx. Browser evidence skipped: deployed preview is auth-gated (next-auth redirect) and no browser automation in session. + +## Design Health Score: 25/40 — Acceptable; solid skeleton, weak edges + +| # | Heuristic | Score | Key Issue | +|---|-----------|:---:|-----------| +| 1 | Visibility of System Status | 3 | Folder create has no pending state; appears only after refetch | +| 2 | Match System / Real World | 4 | Enum→display translation, search in display language, honest "unfiled, not deleted" copy | +| 3 | User Control and Freedom | 2 | No undo; rename input discards silently on blur; menus lack Escape/outside-click close | +| 4 | Consistency and Standards | 2 | Three overlay vocabularies: Headless UI Dialog, window.confirm, bare details popovers | +| 5 | Error Prevention | 3 | Confirm exists; no duplicate-folder-name check; blur-cancel loses typed renames | +| 6 | Recognition Rather Than Recall | 3 | Folder ⋯ is opacity-0 hover-revealed — invisible on touch | +| 7 | Flexibility and Efficiency | 2 | No shortcuts, no bulk actions, no URL state, no Show-all | +| 8 | Aesthetic and Minimalist Design | 3 | Table crams folder into Updated cell | +| 9 | Error Recovery | 1 | Rollback message renders ONLY into sr-only region — sighted users see changes silently vanish; createMutation swallows errors | +| 10 | Help and Documentation | 2 | "Drag to reorder" hint hidden exactly where drag doesn't work (mobile) | + +## Anti-Patterns Verdict + +Not slop (A): no banned patterns, schema-honest, restrained register; failures are under-engineering (native confirm, raw details menus, hex drift), not AI over-decoration. Detector (B): zero findings on all four files — but regex mode is blind to the real issues below (false negatives, not false positives). + +## Where A and B converge (highest-confidence findings) + +- window.confirm at PortfolioHome.tsx:372 (A: register break; B: fact) — with B's irony: it's currently the page's most accessible dialog; the replacement must not regress focus handling. +- details/summary menus have no Esc/outside-click close, no menu semantics; two can be open at once (A heuristic 3/4; B verified no such JS exists). +- Invisible failure states: announceError feeds only the sr-only region (A P1; B verified role=status is sr-only). +- Touch gaps: folder ⋯ trigger invisible without hover (B fact; A recognition issue). + +## B-only mechanical findings + +- Contrast FAILs: placeholder gray-400 on white 2.54:1 (Tailwind default, never overridden); gray-500 counts on active rail bg 3.97:1 and hover 4.11:1. All 8 pills PASS (6.37–8.57). Gold star fill 1.77:1 but aria-hidden decorative with adjacent label + 17.33:1 stroke carrying state. +- Hit targets: FolderRail ⋯ 23×23 (FAIL 24px min); rail "New" ~20px tall (FAIL). CardMenu exactly 24. Star 25. Others pass. +- Table th lack scope="col"; "Your folders" header is a div not a heading; ✓/⚠ glyphs rely on aria-label-on-span/title (unreliable AT support); folder-name input has focus:outline-none with no focus state remaining. +- Hex drift: greys/blues inline (#c3ccd8 #e2e7ee #e4e9f1 #e9edf3 #f8fafc #eceefb) while brand palette exists in tailwind.config. +- Hygiene clean: no TODOs, no !important, one justified inline style, z-20 only, no unused imports. + +## Priority Issues + +- [P1] Replace window.confirm with inline two-step confirm in the folder menu panel ("Unfiles 12 portfolios · Remove / Cancel", red confirm, focus moves to it, Escape cancels). Chosen over dialog (over-weights a non-destructive action; modal-as-first-thought ban) and undo-toast (needs toast system + server restore API since folder recreation mints a new id). +- [P1] Make mutation failures visible: shared visible status + live region; createMutation needs error+pending handling. +- [P1] List-view CardMenu is clipped by the table's overflow-x-auto container — menu can be invisible with focus inside it. Portal/fixed/popover required. +- [P1] No bulk filing: organising 81 portfolios is one-at-a-time (~250+ clicks). Multi-select + "Move N to folder", and/or rail folders as drop targets. +- [P2] Standardise one popover primitive (Esc, outside-click, menu semantics, single-open) — heals heuristic 4. + +## Persona Red Flags + +Alex: no keyboard path to search/open; view/search/sort lost on refresh (no URL state); Show-more ×3 with no Show-all; cards re-sort under cursor on star. +Sam: details menus announce as disclosures not menus; clipped menu can contain focus while invisible; good: aria-pressed + live announcements on star, drag has menu fallback, focus-visible reveals hidden trigger. + +## Minor + +Hex drift → tokenise; "Folder order saved" announced before server confirms; star-teleport re-sort mid-scan; folder deserves own list-view column; NewPortfolioModal clashes (out of scope); no duplicate-name guard. + +## Questions + +- Rail folders as live drop targets — would drag-to-file make bulk-organise disappear? +- At 81 portfolios, is this a browse surface or a dispatcher (recents + starred + command palette first)? diff --git a/src/app/components/home/FolderRail.tsx b/src/app/components/home/FolderRail.tsx index e215e846..43ffba99 100644 --- a/src/app/components/home/FolderRail.tsx +++ b/src/app/components/home/FolderRail.tsx @@ -44,6 +44,9 @@ export default function FolderRail(props: FolderRailProps) { const [dropIndex, setDropIndex] = useState(null); const [creating, setCreating] = useState(false); const [editingId, setEditingId] = useState(null); + // Two-step destructive confirm lives inside the menu panel, not a dialog: + // removal only unfiles portfolios, so OS-modal weight isn't warranted. + const [confirmingId, setConfirmingId] = useState(null); const isFolderView = (folderId: string) => typeof props.view === "object" && props.view.folderId === folderId; @@ -85,7 +88,7 @@ export default function FolderRail(props: FolderRailProps) { aria-label="Folder name" onBlur={onDone} onKeyDown={(e) => e.key === "Escape" && onDone()} - className="w-full rounded-md border border-brandmidblue px-2 py-1 text-sm focus:outline-none" + className="w-full rounded-md border border-brandmidblue px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-brandmidblue/30" /> ); @@ -100,7 +103,7 @@ export default function FolderRail(props: FolderRailProps) { > Starred - + {props.starredCount} @@ -111,7 +114,7 @@ export default function FolderRail(props: FolderRailProps) { > All portfolios - + {props.totalCount} @@ -124,7 +127,7 @@ export default function FolderRail(props: FolderRailProps) { @@ -170,67 +173,115 @@ export default function FolderRail(props: FolderRailProps) { > {folder.name} - + {props.countOf(folder.id)} -
    +
    { + if (!e.currentTarget.open) setConfirmingId(null); + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + const details = e.currentTarget; + details.removeAttribute("open"); + setConfirmingId(null); + details.querySelector("summary")?.focus(); + } + }} + >
    - {( - [ - ["Move up", index > 0, -1], - ["Move down", index < props.folders.length - 1, 1], - ] as const - ).map(([label, enabled, delta]) => ( - - ))} -
    - - + {confirmingId === folder.id ? ( +
    +

    + Remove “{folder.name}”? +

    +

    + {props.countOf(folder.id) === 0 + ? "It's empty — no portfolios are affected." + : `Its ${props.countOf(folder.id)} portfolio${ + props.countOf(folder.id) === 1 ? "" : "s" + } will be unfiled, not deleted.`} +

    +
    + + +
    +
    + ) : ( + <> + {( + [ + ["Move up", index > 0, -1], + ["Move down", index < props.folders.length - 1, 1], + ] as const + ).map(([label, enabled, delta]) => ( + + ))} +
    + + + + )}
    diff --git a/src/app/components/home/PortfolioCard.tsx b/src/app/components/home/PortfolioCard.tsx index 35df8e81..247a25fd 100644 --- a/src/app/components/home/PortfolioCard.tsx +++ b/src/app/components/home/PortfolioCard.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { MoreVertical, Star } from "lucide-react"; import { @@ -73,14 +74,45 @@ function CardMenu({ portfolio, actions, openUpward, + escapeClipping, }: { portfolio: PortfolioWire; actions: CardActions; openUpward?: boolean; + // Inside the list view's overflow-x-auto container an absolute panel gets + // clipped; fixed positioning (measured on open) escapes it. + escapeClipping?: boolean; }) { const currentFolderId = actions.folderIdOf(portfolio.id); + const [fixedPosition, setFixedPosition] = useState<{ + top: number; + right: number; + } | null>(null); return ( -
    +
    { + if (!escapeClipping) return; + if (!e.currentTarget.open) { + setFixedPosition(null); + return; + } + const rect = e.currentTarget + .querySelector("summary")! + .getBoundingClientRect(); + setFixedPosition({ + top: rect.bottom + 4, + right: window.innerWidth - rect.right, + }); + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + const details = e.currentTarget; + details.removeAttribute("open"); + details.querySelector("summary")?.focus(); + } + }} + >
    @@ -107,9 +142,7 @@ function CardMenu({ > {folder.name} {currentFolderId === folder.id && ( - - ✓ - + )} ))} @@ -123,9 +156,7 @@ function CardMenu({ > No folder {currentFolderId === null && ( - - ✓ - + )}
    @@ -240,14 +271,14 @@ export function PortfolioTable({ - - - - - - - + + + + + + @@ -286,7 +317,7 @@ export function PortfolioTable({ {formatUpdatedAgo(portfolio.updatedAt, new Date())} ))} diff --git a/src/app/components/home/PortfolioHome.tsx b/src/app/components/home/PortfolioHome.tsx index feb883e9..0e60d320 100644 --- a/src/app/components/home/PortfolioHome.tsx +++ b/src/app/components/home/PortfolioHome.tsx @@ -99,7 +99,14 @@ export default function PortfolioHome({ setVisibleCount(PAGE_SIZE); }; const [announcement, setAnnouncement] = useState(""); + const [errorNotice, setErrorNotice] = useState(""); const [newPortfolioOpen, setNewPortfolioOpen] = useState(false); + // Failures must be seen, not just announced: feed the sr-only live region + // AND a visible notice. + const announceError = (message: string) => { + setAnnouncement(message); + setErrorNotice(message); + }; const configMutation = useOptimisticHomeMutation( (vars: { @@ -118,7 +125,7 @@ export default function PortfolioHome({ }), }), }), - setAnnouncement, + announceError, ); const reorderMutation = useOptimisticHomeMutation( @@ -131,7 +138,7 @@ export default function PortfolioHome({ position: orderedIds.indexOf(f.id), })), }), - setAnnouncement, + announceError, ); const renameMutation = useOptimisticHomeMutation( @@ -143,7 +150,7 @@ export default function PortfolioHome({ f.id === vars.folderId ? { ...f, name: vars.name } : f, ), }), - setAnnouncement, + announceError, ); const deleteMutation = useOptimisticHomeMutation( @@ -155,11 +162,12 @@ export default function PortfolioHome({ c.folderId === folderId ? { ...c, folderId: null } : c, ), }), - setAnnouncement, + announceError, ); const createMutation = useMutation({ mutationFn: (name: string) => send("/api/me/folders", "POST", { name }), + onError: () => announceError("Couldn\u2019t create the folder \u2014 try again"), onSettled: () => queryClient.invalidateQueries(HOME_KEY), }); @@ -284,7 +292,7 @@ export default function PortfolioHome({ onChange={(e) => chooseQuery(e.target.value)} placeholder="Search portfolios" aria-label="Search portfolios by name, status, goal or folder" - className="w-60 rounded-lg border border-[#c3ccd8] bg-white py-2 pl-8 pr-3 text-sm" + className="w-60 rounded-lg border border-[#c3ccd8] bg-white py-2 pl-8 pr-3 text-sm placeholder:text-gray-500" /> { + e.dataTransfer.setData("application/x-portfolio", portfolio.id); + e.dataTransfer.effectAllowed = "move"; + }} className="border-b border-[#eef1f6] last:border-b-0 hover:bg-[#f8fafc]" > ))} diff --git a/src/app/components/home/PortfolioHome.tsx b/src/app/components/home/PortfolioHome.tsx index 0e60d320..46be7dd1 100644 --- a/src/app/components/home/PortfolioHome.tsx +++ b/src/app/components/home/PortfolioHome.tsx @@ -72,15 +72,23 @@ function useOptimisticHomeMutation( }); } +function viewToUrl(view: RailView): string { + if (view === "starred") return "/home?view=starred"; + if (typeof view === "object") return `/home?folder=${view.folderId}`; + return "/home"; +} + export default function PortfolioHome({ initialDisplay = "grid", + initialView = "all", }: { initialDisplay?: "grid" | "list"; + initialView?: RailView; }) { const queryClient = useQueryClient(); const { data, isLoading, isError, refetch } = useQuery(HOME_KEY, fetchHome); - const [view, setView] = useState("all"); + const [view, setView] = useState(initialView); const [display, setDisplay] = useState<"grid" | "list">(initialDisplay); const chooseDisplay = (value: "grid" | "list") => { setDisplay(value); @@ -89,10 +97,13 @@ export default function PortfolioHome({ const [sort, setSort] = useState("updated"); const [query, setQuery] = useState(""); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); - // Changing what's listed resets how much of it is revealed. + // Changing what's listed resets how much of it is revealed. The selection + // also lands in the URL (replaceState — no navigation) so refresh and + // sharing keep the folder context. const chooseView = (next: RailView) => { setView(next); setVisibleCount(PAGE_SIZE); + window.history.replaceState(null, "", viewToUrl(next)); }; const chooseQuery = (next: string) => { setQuery(next); @@ -206,6 +217,11 @@ export default function PortfolioHome({ (a, b) => a.position - b.position, ); + // A folder id from the URL or a just-deleted folder may not exist; fall + // back to All rather than rendering a ghost view. + const activeView: RailView = + typeof view === "object" && !folderById.has(view.folderId) ? "all" : view; + const isStarred = (id: string) => configOf.get(id)?.starredAt != null; const folderIdOf = (id: string) => configOf.get(id)?.folderId ?? null; const folderNameOf = (id: string) => { @@ -216,18 +232,18 @@ export default function PortfolioHome({ statusDisplay(p.status).needsAttention; const inView = (p: PortfolioWire) => - view === "all" + activeView === "all" ? true - : view === "starred" + : activeView === "starred" ? isStarred(p.id) - : folderIdOf(p.id) === view.folderId; + : folderIdOf(p.id) === activeView.folderId; const scope = data.portfolios.filter(inView); const filtered = scope.filter((p) => matchesSearch({ ...p, folderName: folderNameOf(p.id) }, query), ); let rows = sortPortfolios(filtered, sort); - if (view === "starred") { + if (activeView === "starred") { rows = [...rows].sort( (a, b) => new Date(configOf.get(b.id)!.starredAt!).getTime() - @@ -243,11 +259,11 @@ export default function PortfolioHome({ const hiddenCount = rows.length - visibleRows.length; const viewTitle = - view === "all" + activeView === "all" ? "All portfolios" - : view === "starred" + : activeView === "starred" ? "Starred" - : (folderById.get(view.folderId)?.name ?? "Folder"); + : (folderById.get(activeView.folderId)?.name ?? "Folder"); const attentionCount = rows.filter(needsAttention).length; const propertiesSum = rows.reduce( (sum, p) => sum + (p.numberOfProperties ?? 0), @@ -360,7 +376,7 @@ export default function PortfolioHome({
    isStarred(p.id)).length} attentionTotal={data.portfolios.filter(needsAttention).length} @@ -378,10 +394,29 @@ export default function PortfolioHome({ setAnnouncement("Folder order saved"); }} onCreate={(name) => createMutation.mutate(name)} + onDropToFolder={(portfolioId, folderId) => { + const portfolio = data.portfolios.find((p) => p.id === portfolioId); + if (!portfolio || folderIdOf(portfolioId) === folderId) return; + configMutation.mutate({ portfolioId, change: { folderId } }); + setAnnouncement( + folderId + ? `Moved ${portfolio.name} to ${folderById.get(folderId)?.name}` + : `Removed ${portfolio.name} from its folder`, + ); + }} + onDropToStarred={(portfolioId) => { + const portfolio = data.portfolios.find((p) => p.id === portfolioId); + if (!portfolio || isStarred(portfolioId)) return; + configMutation.mutate({ portfolioId, change: { starred: true } }); + setAnnouncement(`Starred ${portfolio.name}`); + }} onRename={(folderId, name) => renameMutation.mutate({ folderId, name }) } onDelete={(folderId) => { + if (typeof activeView === "object" && activeView.folderId === folderId) { + chooseView("all"); + } deleteMutation.mutate(folderId); setAnnouncement("Folder removed — its portfolios are unfiled"); }} @@ -440,7 +475,7 @@ export default function PortfolioHome({
    ) : (
    - {view === "starred" ? ( + {activeView === "starred" ? ( <> Nothing starred yet. @@ -448,7 +483,7 @@ export default function PortfolioHome({ Star a portfolio to keep your day-to-day work one click away. - ) : view === "all" ? ( + ) : activeView === "all" ? ( <> No portfolios yet. @@ -473,7 +508,7 @@ export default function PortfolioHome({ portfolio={portfolio} actions={cardActions} folderName={ - view === "all" || view === "starred" + activeView === "all" || activeView === "starred" ? folderNameOf(portfolio.id) : null } @@ -485,7 +520,7 @@ export default function PortfolioHome({ portfolios={visibleRows} actions={cardActions} folderNameOf={ - view === "all" || view === "starred" ? folderNameOf : () => null + activeView === "all" || activeView === "starred" ? folderNameOf : () => null } /> )} diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index 4111b06d..ddc87884 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -4,7 +4,9 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import PortfolioHome from "../components/home/PortfolioHome"; -const Home = async () => { +const Home = async (props: { + searchParams: Promise<{ folder?: string; view?: string }>; +}) => { const user = await getServerSession(AuthOptions); if (!user?.user) { @@ -17,6 +19,18 @@ const Home = async () => { const initialDisplay = cookieStore.get("home-display")?.value === "list" ? "list" : "grid"; - return ; + // The selected rail view survives refresh via the URL; an unknown folder id + // falls back to All client-side once data loads. + const searchParams = await props.searchParams; + const initialView = + searchParams.view === "starred" + ? ("starred" as const) + : /^\d+$/.test(searchParams.folder ?? "") + ? { folderId: searchParams.folder! } + : ("all" as const); + + return ( + + ); }; export default Home; From 8f7f5b3a88ed9d553740cb81fd2caa3f8beb6a49 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 17:37:16 +0000 Subject: [PATCH 32/37] fix(modelling): set-and-forget trigger state, visible back link, filter clearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review UX feedback on the run journey: - The post-trigger card computed its ETA from the just-cleared scenario selection, so it always promised "~5 minutes" whatever the run size. ETA now comes from the captured run (estimateRunMinutes, TDD'd) and the done state is a compact status banner above a still-visible Recent runs table, which polls every 5s while a run is moving (historyRefetchInterval, TDD'd) — trigger, glance, leave. - The only way back was a text-xs gray-400 breadcrumb (~2.5:1, sub-AA); replaced with an accessible "← Back to {portfolio}" link. - "Remove all filters" button on Step 2. - Scenario rows were a checkbox nested inside a button (state invisible to screen readers); now a label wrapping a real checkbox. - Large-run confirm lists the scenario names and selection being committed, is labelled, closes on Escape, and starts focus on "Go back". - Postcode filter no longer fails silently: loading and error-with-retry states. - Run status "Dispatched" reads "Starting up"; scenarios page gains a "Run modelling" entry point. Co-Authored-By: Claude Fable 5 --- .../[slug]/(portfolio)/scenarios/page.tsx | 24 ++- .../modelling/run/RunModellingClient.tsx | 194 ++++++++++++------ src/lib/modellingRuns/model.test.ts | 35 ++++ src/lib/modellingRuns/model.ts | 24 +++ 4 files changed, 205 insertions(+), 72 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx index 05113760..ba1afc38 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx @@ -40,13 +40,23 @@ export default async function ScenariosPage(props: { }} />
    - - + New scenario - +
    + {scenarios.length > 0 && ( + + Run modelling + + )} + + + New scenario + +

    Each scenario is a question you ask the modelling engine. Its diff --git a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx index 68b6c691..3d4808d4 100644 --- a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx +++ b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx @@ -2,16 +2,18 @@ import { useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { PlayIcon } from "@heroicons/react/24/solid"; +import { ArrowLeftIcon, CheckCircleIcon } from "@heroicons/react/24/solid"; import { RUN_FILTER_BUILT_FORMS, RUN_FILTER_PROPERTY_TYPES, RunFilters, RunStatus, + estimateRunMinutes, + historyRefetchInterval, isLargeRun, MAX_FILTER_POSTCODES, + selectionSummary, } from "@/lib/modellingRuns/model"; export interface ScenarioOption { @@ -42,7 +44,7 @@ interface RunHistoryRow { } const STATUS_LABELS: Record = { - dispatched: "Dispatched", + dispatched: "Starting up", in_progress: "In progress", complete: "Complete", failed: "Failed", @@ -128,7 +130,6 @@ export default function RunModellingClient({ portfolioName: string; scenarios: ScenarioOption[]; }) { - const router = useRouter(); const queryClient = useQueryClient(); const [selScenarios, setSelScenarios] = useState>(new Set()); const [selPcs, setSelPcs] = useState>(new Set()); @@ -174,7 +175,8 @@ export default function RunModellingClient({ const history = useQuery({ queryKey: ["modelling-runs", portfolioId], - refetchInterval: 30_000, + // v4 signature: (data, query). Fast while a run is moving, slow at rest. + refetchInterval: (data) => historyRefetchInterval(data), queryFn: async () => { const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`); const body = await res.json(); @@ -220,16 +222,22 @@ export default function RunModellingClient({ if (matched != null && isLargeRun(matched)) setConfirmOpen(true); else run.mutate(); }; - const eta = planTotal ? Math.max(5, Math.round(planTotal / 300) * 5) : 5; + const filterCount = selPcs.size + selTypes.size + selForms.size; + const clearFilters = () => { + setSelPcs(new Set()); + setSelTypes(new Set()); + setSelForms(new Set()); + }; return (

    -
    - - {portfolioName} / Portfolio - {" "} - / Run modelling -
    + + + Back to {portfolioName} +

    Run your scenarios.

    @@ -239,34 +247,42 @@ export default function RunModellingClient({

    {done ? ( -
    -
    - -
    -

    Modelling started

    -

    - We're modelling {done.properties.toLocaleString()}{" "} - {done.properties === 1 ? "property" : "properties"} against {done.scenarios}{" "} - scenario{done.scenarios > 1 ? "s" : ""} —{" "} - {(done.properties * done.scenarios).toLocaleString()} plan - {done.properties * done.scenarios === 1 ? "" : "s"} in total. Results for a run this - size are typically ready within ~{eta} minutes. You can check on this run any - time under Recent runs. -

    -
    - - +
    +
    + +
    +

    + Modelling started — you don't need to stay on this page +

    +

    + We're modelling {done.properties.toLocaleString()}{" "} + {done.properties === 1 ? "home" : "homes"} against {done.scenarios}{" "} + scenario{done.scenarios === 1 ? "" : "s"} —{" "} + {(done.properties * done.scenarios).toLocaleString()} plan + {done.properties * done.scenarios === 1 ? "" : "s"}. Results for a run this size + are typically ready within{" "} + ~{estimateRunMinutes(done.properties * done.scenarios)} minutes — progress + updates under Recent runs below as it happens. +

    +
    +
    + + + Back to portfolio + +
    ) : ( @@ -298,9 +314,8 @@ export default function RunModellingClient({ const on = selScenarios.has(s.id); return (
    - + {on && s.status === "running" && (
    This scenario is already being modelled. You can still include it — the @@ -341,9 +355,19 @@ export default function RunModellingClient({ {/* Step 2 — properties */}
    - - Step 2 · Properties - +
    + + Step 2 · Properties + + {filterCount > 0 && ( + + )} +

    Which homes?

    @@ -353,15 +377,39 @@ export default function RunModellingClient({ neither.

    - ({ - value: p.postcode, - count: p.count, - }))} - selected={selPcs} - onToggle={(v) => toggle(selPcs, setSelPcs, v)} - /> + {postcodesQuery.isError ? ( +
    + + Postcode + +

    + We couldn't load your portfolio's postcodes.{" "} + +

    +
    + ) : postcodesQuery.isLoading ? ( +
    + + Postcode + +

    Loading postcodes…

    +
    + ) : ( + ({ + value: p.postcode, + count: p.count, + }))} + selected={selPcs} + onToggle={(v) => toggle(selPcs, setSelPcs, v)} + /> + )} ({ value }))} @@ -381,8 +429,12 @@ export default function RunModellingClient({

    - {/* Recent runs */} -
    + + )} + + {/* Recent runs — always visible: after a run is triggered this is where + its live status lands, so the banner above can say "watch below" */} +

    Recent runs

    @@ -470,9 +522,7 @@ export default function RunModellingClient({
    - PortfolioStatusGoalPropertiesBudgetUpdated + + PortfolioStatusGoalPropertiesBudgetUpdated
    - +
    @@ -317,7 +294,7 @@ export function PortfolioTable({ {formatUpdatedAgo(portfolio.updatedAt, new Date())} - +
    )} -
    - - )} +
    {/* Summary tray */} {!done && scenarioIds.length > 0 && ( @@ -545,10 +595,14 @@ export default function RunModellingClient({
    { + if (e.key === "Escape") setConfirmOpen(false); + }} className="fixed inset-0 z-50 flex items-center justify-center bg-brandblue/35 p-5 backdrop-blur-sm" >
    -

    +

    This is a large run

    @@ -557,8 +611,18 @@ export default function RunModellingClient({ {planTotal?.toLocaleString()} plans. A run this size can't be stopped once it starts.

    +
    + + Scenarios:{" "} + {scenarioIds.map((id) => scenarioById.get(id)?.name ?? id).join(" · ")} + + + Homes: {selectionSummary(filters)} + +