diff --git a/cypress/e2e/live-tracking/pibi-dates.cy.js b/cypress/e2e/live-tracking/pibi-dates.cy.js deleted file mode 100644 index d5cc717..0000000 --- a/cypress/e2e/live-tracking/pibi-dates.cy.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Live Tracking — PIBI dates editor (issue #252) - * - * Verifies the write-role flow on the PIBI section of the property detail - * drawer: a `write` user can pick PIBI order and completion dates, hit - * Save, and the chosen dates are still reflected after the page is - * reloaded (i.e. the values were persisted server-side). - * - * The spec assumes an authenticated `write` session can be reused (or the - * test harness logs in via the same flow as the rest of the suite). The - * target portfolio slug + a deal id with PIBI fields editable for the - * write user are read from Cypress env vars so the spec stays portable. - */ - -const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); -const TARGET_DEAL_NAME = Cypress.env("LIVE_PIBI_DEAL_NAME"); - -const ORDER_DATE = "2025-03-12"; -const COMPLETED_DATE = "2025-04-02"; - -describe("PIBI dates editor — write user flow", function () { - before(function () { - if (!PORTFOLIO_SLUG) { - cy.log( - "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", - ); - this.skip(); - } - }); - - function openDrawerForTargetDeal() { - cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); - - // Switch to the Measures tab — the easiest way into the drawer. - cy.contains("button, [role=tab]", "Measures").click(); - - if (TARGET_DEAL_NAME) { - cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click(); - } else { - cy.get("[data-testid=measures-row]").first().click(); - } - - cy.get("[data-testid=property-detail-drawer]").should("be.visible"); - // Navigate to the PIBI tab (drawer opens on Works tab from Measures row click). - cy.get("[data-testid=drawer-tab-pibi]").click(); - cy.get("[data-testid=drawer-section-pibi]").should("exist"); - } - - it("lets a write user set PIBI order + completion dates and persists them across reload", () => { - openDrawerForTargetDeal(); - - // Both date inputs render for write+ users. - cy.get("[data-testid=pibi-order-date-input]").should("be.visible"); - cy.get("[data-testid=pibi-completed-date-input]").should("be.visible"); - - cy.get("[data-testid=pibi-order-date-input]") - .clear() - .type(ORDER_DATE); - cy.get("[data-testid=pibi-completed-date-input]") - .clear() - .type(COMPLETED_DATE); - - // Save button should be enabled once values change. - cy.get("[data-testid=pibi-save-button]") - .should("not.be.disabled") - .click(); - - // Saving completes — the button label flips back from "Saving…" to - // "Save PIBI Dates" and no error banner is shown. - cy.get("[data-testid=pibi-save-button]").should( - "contain.text", - "Save PIBI Dates", - ); - cy.get("[data-testid=pibi-error]").should("not.exist"); - - // Optimistic update — the inputs already reflect the new values. - cy.get("[data-testid=pibi-order-date-input]").should( - "have.value", - ORDER_DATE, - ); - cy.get("[data-testid=pibi-completed-date-input]").should( - "have.value", - COMPLETED_DATE, - ); - - // Reload the page and reopen the drawer — the persisted values must - // still be there. - cy.reload(); - openDrawerForTargetDeal(); - - cy.get("[data-testid=pibi-order-date-input]").should( - "have.value", - ORDER_DATE, - ); - cy.get("[data-testid=pibi-completed-date-input]").should( - "have.value", - COMPLETED_DATE, - ); - }); -}); diff --git a/cypress/e2e/live-tracking/pibi-measures.cy.js b/cypress/e2e/live-tracking/pibi-measures.cy.js deleted file mode 100644 index 95ef81e..0000000 --- a/cypress/e2e/live-tracking/pibi-measures.cy.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Live Tracking — PIBI measure selection flow (issue #254) - * - * Verifies the approver flow for selecting which measures on a deal go for - * PIBI: - * 1. the approver opens the property drawer at the PIBI section, - * 2. ticks and unticks measures in the multi-select, - * 3. saves the selection — the drawer reflects the ticked state, - * 4. the POST hits the pibi-measures route which pushes - * `measures_for_pibi_ordered` back to HubSpot. - * - * Mirrors `instruct-measure.cy.js`. The spec uses `cy.intercept` so the - * HubSpot push side-effect is observable without a real CRM round-trip. - */ - -const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); -const TARGET_DEAL_NAME = Cypress.env("LIVE_PIBI_DEAL_NAME"); - -describe("PIBI measure selection — approver flow", function () { - before(function () { - if (!PORTFOLIO_SLUG) { - cy.log( - "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", - ); - this.skip(); - } - }); - - function openDrawerAtPibiSection() { - cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); - - // Open a property row to get the detail drawer. - cy.contains("button, [role=tab]", "Measures").click(); - - if (TARGET_DEAL_NAME) { - cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click(); - } else { - cy.get("[data-testid=measures-row]").first().click(); - } - - cy.get("[data-testid=property-detail-drawer]").should("be.visible"); - - // Navigate to the PIBI tab (drawer opens on Works tab from Measures row click). - cy.get("[data-testid=drawer-tab-pibi]").click(); - cy.get("[data-testid=drawer-section-pibi]").should("exist"); - } - - it("fetches the PIBI state and shows the multi-select for approvers", () => { - // Stub the GET so we control the initial state. - cy.intercept( - "GET", - `/api/portfolio/*/pibi-measures*`, - { - body: { - pibiMeasures: ["ASHP"], - approvedMeasures: ["ASHP", "Solar PV"], - instructedMeasures: [], - }, - }, - ).as("getPibiMeasures"); - - openDrawerAtPibiSection(); - - cy.wait("@getPibiMeasures"); - - // The multi-select should be visible for approvers. - cy.get("[data-testid=pibi-measure-selector]").should("be.visible"); - - // ASHP should be checked (was in pibiMeasures). - cy.get("[data-testid=pibi-measure-checkbox-ASHP]").should( - "be.checked", - ); - }); - - it("lets an approver tick/untick selections and POST to the route", () => { - // Stub GET to return a clean state. - cy.intercept( - "GET", - `/api/portfolio/*/pibi-measures*`, - { - body: { - pibiMeasures: [], - approvedMeasures: ["ASHP", "Solar PV"], - instructedMeasures: [], - }, - }, - ).as("getPibiMeasures"); - - // Intercept the POST so we can assert the body. - cy.intercept( - "POST", - `/api/portfolio/*/pibi-measures`, - ).as("savePibiMeasures"); - - openDrawerAtPibiSection(); - - cy.wait("@getPibiMeasures"); - - cy.get("[data-testid=pibi-measure-selector]").should("be.visible"); - - // Both approved measures (ASHP, Solar PV) should be pre-ticked since - // pibiMeasures was empty and approvedMeasures had values. - cy.get("[data-testid=pibi-measure-checkbox-ASHP]").should("be.checked"); - cy.get("[data-testid=pibi-measure-checkbox-Solar PV]").should("be.checked"); - - // Untick ASHP. - cy.get("[data-testid=pibi-measure-option-ASHP]").click(); - cy.get("[data-testid=pibi-measure-checkbox-ASHP]").should("not.be.checked"); - - // Save the selection. - cy.get("[data-testid=pibi-selector-save]").click(); - - cy.wait("@savePibiMeasures").then((intercepted) => { - // Body should reflect the new selection (Solar PV only). - expect(intercepted.request.body).to.have.property("dealId"); - expect(intercepted.request.body.measureNames).to.include("Solar PV"); - expect(intercepted.request.body.measureNames).not.to.include("ASHP"); - - // Route returns ok=true. - expect(intercepted.response.statusCode).to.be.oneOf([200, 201]); - expect(intercepted.response.body).to.have.property("ok", true); - expect(intercepted.response.body).to.have.property( - "hubspotSync", - ); - }); - - // No error banner visible. - cy.get("[data-testid=pibi-selector-error]").should("not.exist"); - }); - - it("pushes measures_for_pibi_ordered to HubSpot on a successful save", () => { - cy.intercept( - "GET", - `/api/portfolio/*/pibi-measures*`, - { - body: { - pibiMeasures: ["CWI"], - approvedMeasures: ["CWI"], - instructedMeasures: [], - }, - }, - ).as("getPibiMeasures"); - - // Stub the POST to confirm the property pushed. - cy.intercept("POST", `/api/portfolio/*/pibi-measures`, { - body: { ok: true, hubspotSync: "ok" }, - }).as("savePibiMeasures"); - - openDrawerAtPibiSection(); - - cy.wait("@getPibiMeasures"); - - cy.get("[data-testid=pibi-selector-save]").click(); - - cy.wait("@savePibiMeasures").then((intercepted) => { - // Confirm the POST body contains the right measures. - expect(intercepted.request.body).to.have.property("measureNames"); - // Response signals a successful HubSpot push. - expect(intercepted.response.body.hubspotSync).to.equal("ok"); - }); - }); -}); diff --git a/cypress/e2e/live-tracking/pibi-section.cy.js b/cypress/e2e/live-tracking/pibi-section.cy.js new file mode 100644 index 0000000..cbcb5db --- /dev/null +++ b/cypress/e2e/live-tracking/pibi-section.cy.js @@ -0,0 +1,286 @@ +/** + * Live Tracking — PibiSection (flat TanStack Table redesign) + * + * Tests the approver flow for the per-measure PIBI request log rendered + * directly on the DealPage (pibi-surveys tab), not in a drawer. + * + * 1. Empty state renders with a "Log first PIBI" prompt + * 2. Flat table shows existing rows (no batch grouping) + * 3. Every row has always-editable cells (measure select, date inputs) + * 4. Save button is disabled when row is clean, enabled after editing + * 5. Save calls PATCH for existing rows + * 6. Delete calls DELETE + * 7. "+ Add row" appends a blank row; save calls POST + * 8. "Mark all complete" PATCHes all incomplete rows + * 9. Scope badges appear for approved / proposed measures + * + * Requires LIVE_PORTFOLIO_SLUG; skipped otherwise. + * All network calls are intercepted — no real DB / HubSpot round-trips. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); +const PORTFOLIO_ID_GLOB = "*"; + +function stubGet(pibiRequests = []) { + cy.intercept( + "GET", + `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests*`, + { body: { pibiRequests } }, + ).as("getPibiRequests"); +} + +function stubMeasures( + approvedMeasures = ["ASHP", "CWI"], + instructedMeasures = [], +) { + cy.intercept( + "GET", + `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-measures*`, + { body: { pibiMeasures: approvedMeasures, approvedMeasures, instructedMeasures } }, + ).as("getPibiMeasures"); +} + +function stubPost(response = { ok: true, insertedCount: 1, hubspotSync: "ok" }) { + cy.intercept( + "POST", + `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests`, + { body: response }, + ).as("postPibiRequest"); +} + +function stubPatch(id, response = { ok: true, hubspotSync: "ok" }) { + cy.intercept( + "PATCH", + `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests/${id}`, + { body: response }, + ).as(`patchPibiRequest-${id}`); +} + +function stubDelete(id, response = { ok: true, hubspotSync: "ok" }) { + cy.intercept( + "DELETE", + `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests/${id}*`, + { body: response }, + ).as(`deletePibiRequest-${id}`); +} + +function openDealPageAtPibiTab() { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + cy.contains("button, [role=tab]", "Properties").click(); + cy.get("[data-testid=property-row-link]").first().click(); + cy.get("[data-testid=deal-page-tab-pibi-surveys]").click(); +} + +describe("PibiSection", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log("LIVE_PORTFOLIO_SLUG not set — skipping PibiSection specs"); + this.skip(); + } + }); + + beforeEach(() => { + stubMeasures(); + }); + + // ── Cycle 1: Empty state ────────────────────────────────────────────────── + + it("shows empty state with Log first PIBI prompt when no requests exist", () => { + stubGet([]); + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + cy.get("[data-testid=pibi-empty-state]").should("be.visible"); + cy.get("[data-testid=pibi-empty-state]").should( + "contain.text", + "No PIBIs logged yet", + ); + cy.get("[data-testid=pibi-empty-add-row]").should("be.visible"); + }); + + // ── Cycle 2: Flat table renders rows (no batch groups) ──────────────────── + + it("renders a flat table of rows with no batch grouping", () => { + stubGet([ + { + id: "1", + measureName: "ASHP", + orderedAt: "2026-05-01T00:00:00.000Z", + completedAt: null, + }, + { + id: "2", + measureName: "CWI", + orderedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + }, + ]); + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-row-1]").should("be.visible"); + cy.get("[data-testid=pibi-row-2]").should("be.visible"); + cy.get("[data-testid=pibi-batch-group]").should("not.exist"); + }); + + // ── Cycle 3: Always-editable cells ──────────────────────────────────────── + + it("renders measure select and date inputs as always-editable cells", () => { + stubGet([ + { + id: "5", + measureName: "ASHP", + orderedAt: "2026-05-01T00:00:00.000Z", + completedAt: null, + }, + ]); + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-measure-select-5]").should("be.visible"); + cy.get("[data-testid=pibi-ordered-date-5]").should("be.visible"); + cy.get("[data-testid=pibi-completed-date-5]").should("be.visible"); + }); + + // ── Cycle 4: Save disabled when clean, enabled after editing ────────────── + + it("shows Save disabled on load, enabled after editing a cell", () => { + stubGet([ + { + id: "6", + measureName: "ASHP", + orderedAt: "2026-05-01T00:00:00.000Z", + completedAt: null, + }, + ]); + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-save-6]").should("be.disabled"); + cy.get("[data-testid=pibi-ordered-date-6]").clear().type("2026-06-01"); + cy.get("[data-testid=pibi-save-6]").should("not.be.disabled"); + }); + + // ── Cycle 5: Save calls PATCH ───────────────────────────────────────────── + + it("calls PATCH with updated fields when approver clicks Save", () => { + stubGet([ + { + id: "10", + measureName: "ASHP", + orderedAt: "2026-05-01T00:00:00.000Z", + completedAt: null, + }, + ]); + stubPatch("10"); + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-completed-date-10]").type("2026-05-15"); + cy.get("[data-testid=pibi-save-10]").click(); + + cy.wait("@patchPibiRequest-10").then((interception) => { + expect(interception.request.body.dealId).to.be.a("string"); + expect(interception.request.body.completedAt).to.include("2026-05-15"); + }); + }); + + // ── Cycle 6: Delete calls DELETE ────────────────────────────────────────── + + it("calls DELETE when approver clicks Delete on a row", () => { + stubGet([ + { + id: "20", + measureName: "EWI", + orderedAt: "2026-05-01T00:00:00.000Z", + completedAt: null, + }, + ]); + stubDelete("20"); + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-delete-20]").click(); + cy.wait("@deletePibiRequest-20"); + }); + + // ── Cycle 7: Add row → POST ─────────────────────────────────────────────── + + it("appends a blank row on add-row click and POSTs on save", () => { + stubGet([]); + stubPost({ ok: true, insertedCount: 1, hubspotSync: "ok" }); + cy.intercept("GET", `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests*`, { + body: { + pibiRequests: [ + { + id: "99", + measureName: "ASHP", + orderedAt: new Date().toISOString(), + completedAt: null, + }, + ], + }, + }).as("getPibiRequestsAfter"); + + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-empty-add-row]").click(); + cy.get("[data-testid=pibi-section]").find("tr[data-testid^=pibi-row-new]").should("have.length", 1); + + cy.get("[data-testid=pibi-section]") + .find("[data-testid^=pibi-save-new]") + .click(); + + cy.wait("@postPibiRequest").then((interception) => { + expect(interception.request.body.measureNames).to.be.an("array").with.length(1); + expect(interception.request.body.orderedAt).to.be.a("string"); + }); + }); + + // ── Cycle 8: Mark all complete ──────────────────────────────────────────── + + it("PATCHes all incomplete rows when Mark all complete is clicked", () => { + stubGet([ + { + id: "30", + measureName: "ASHP", + orderedAt: "2026-05-01T00:00:00.000Z", + completedAt: null, + }, + { + id: "31", + measureName: "CWI", + orderedAt: "2026-05-01T00:00:00.000Z", + completedAt: null, + }, + ]); + stubPatch("30"); + stubPatch("31"); + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-mark-all-complete]").click(); + cy.wait("@patchPibiRequest-30").then((i) => { + expect(i.request.body.completedAt).to.be.a("string"); + }); + cy.wait("@patchPibiRequest-31"); + }); + + // ── Cycle 9: Scope badges ───────────────────────────────────────────────── + + it("shows Approved badge for a measure in approvedMeasures", () => { + stubGet([ + { + id: "40", + measureName: "ASHP", + orderedAt: "2026-05-01T00:00:00.000Z", + completedAt: null, + }, + ]); + stubMeasures(["ASHP"], []); + openDealPageAtPibiTab(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-row-40]").should("contain.text", "Approved"); + }); +}); diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..e56778a --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "skills": { + "caveman": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/caveman/SKILL.md", + "computedHash": "934433479903febc585bf6deb5f0cebc63137e3f86b7babe0aab1ecb94d6d7a4" + }, + "diagnose": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/diagnose/SKILL.md", + "computedHash": "15939a26f86edec2d4862042b8564e5a062cb81d04e047a0cea6305c8830b5f5" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grill-me/SKILL.md", + "computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea" + }, + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "31a5b1ae116558bf7d3f633f442835f54bd7645923d4f45c7823e52a97317666" + }, + "improve-codebase-architecture": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md", + "computedHash": "c77b86b4332919499608f9af1880074e1fec65a59b95c70c27a9f39cd137865e" + }, + "ralph-loop": { + "source": "Hestia-Homes/agentic-toolkit", + "sourceType": "github", + "skillPath": "skills/engineering/ralph-loop/SKILL.md", + "computedHash": "6d45d44d84abf566d0f298af6b7d710e5f6ebaecb5a06c31fedacd20085ae88d" + }, + "setup-matt-pocock-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md", + "computedHash": "3a32f8f1ed8160c9d286a2aabe88ee9b884c6f3f88a7a6c47b7d5d552c959587" + }, + "tdd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/tdd/SKILL.md", + "computedHash": "15a7b5e36383ebadb2dec5e586679e55e9663d292da418926b8da6fc0ef27d84" + }, + "to-issues": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-issues/SKILL.md", + "computedHash": "73a91f30784523aa59ec9b02769576ebfc738e2cd5ad8f6441076031f0a5d5ac" + }, + "to-prd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-prd/SKILL.md", + "computedHash": "fd8c259f9c44eff08e29a1a2fc71a806a3568d279a55387a361f78620b10f2aa" + }, + "to-project": { + "source": "Hestia-Homes/agentic-toolkit", + "sourceType": "github", + "skillPath": "skills/engineering/to-project/SKILL.md", + "computedHash": "59daf039ac699a44a9416f8ec403b83d4166e05489959e127746231ff8be4e12" + }, + "triage": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/triage/SKILL.md", + "computedHash": "2b6efb6da12d92551772fcc04acf331f4e0e6f7bd9d4cb23ce0b301e0b128feb" + }, + "write-a-skill": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/write-a-skill/SKILL.md", + "computedHash": "b44d8aab2ead83c716e01af4c9a24ccc4575ce70ad58ec4f1749fb88c9cc82ba" + }, + "zoom-out": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/zoom-out/SKILL.md", + "computedHash": "8357aeaece3b709c442eab67e64b86844e05e2f1ea95b109565eba50b6def36e" + } + } +} diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts new file mode 100644 index 0000000..ba668d4 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts @@ -0,0 +1,289 @@ +/** + * Unit tests for the approvals POST handler. + * + * Focuses on HubSpot sync behaviour: after approve/unapprove changes are + * persisted to the DB, the handler must push both the audit log + * (client_measures_approval_log) and the structured field (approved_measures) + * to HubSpot. Prior to this fix only the audit log was synced. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks (declared before vi.mock factories run) ───────────────────── +const { + mockGetServerSession, + syncMeasureApprovalsToHubSpotMock, + syncMeasuresFieldToHubSpotMock, + mockDbSelect, + mockDbInsert, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + syncMeasureApprovalsToHubSpotMock: vi.fn(), + syncMeasuresFieldToHubSpotMock: vi.fn(), + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), +})); + +// ── Auth ────────────────────────────────────────────────────────────────────── +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ + AuthOptions: {}, +})); + +// ── HubSpot syncs ───────────────────────────────────────────────────────────── +vi.mock("@/app/lib/hubspot/dealSync", () => ({ + syncMeasureApprovalsToHubSpot: syncMeasureApprovalsToHubSpotMock, + syncMeasuresFieldToHubSpot: syncMeasuresFieldToHubSpotMock, +})); +vi.mock("@/app/lib/instructMeasure", () => ({ + APPROVED_MEASURES_PROP: "approved_measures", +})); + +// ── Drizzle ORM ─────────────────────────────────────────────────────────────── +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })), + sql: vi.fn(), +})); + +// ── DB schema stubs ─────────────────────────────────────────────────────────── +vi.mock("@/app/db/schema/approvals", () => ({ + dealMeasureApprovals: { hubspotDealId: {}, measureName: {}, isApproved: {}, approvedBy: {}, approvedAt: {} }, + dealMeasureApprovalEvents: { hubspotDealId: {}, measureName: {}, action: {}, actedBy: {}, actedAt: {} }, +})); +vi.mock("@/app/db/schema/portfolio", () => ({ + portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {}, id: {} }, +})); +vi.mock("@/app/db/schema/users", () => ({ + user: { id: {}, email: {}, firstName: {} }, +})); + +// ── DB mock ─────────────────────────────────────────────────────────────────── +vi.mock("@/app/db/db", () => ({ + db: { + get select() { return mockDbSelect; }, + get insert() { return mockDbInsert; }, + }, +})); + +// ── DB mock helpers ──────────────────────────────────────────────────────────── +// Builds a thenable select chain where .limit() resolves to `limitResult` +// and awaiting the chain without .limit() resolves to `directResult`. +function makeSelectChain( + limitResult: unknown[], + directResult: unknown[] = [], +) { + const self: Record = {}; + // thenable so `await chain.where(...)` resolves to directResult + self["then"] = ( + resolve: (v: unknown) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve(directResult).then(resolve, reject); + self["from"] = vi.fn(() => self); + self["leftJoin"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(limitResult)); + return self; +} + +// Builds an insert chain. .values() is thenable (plain insert) and also +// exposes .onConflictDoUpdate() (upsert). +function makeInsertChain() { + const values: Record = {}; + values["then"] = ( + resolve: (v: unknown) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve(undefined).then(resolve, reject); + values["onConflictDoUpdate"] = vi.fn(() => Promise.resolve(undefined)); + const insert = { values: vi.fn(() => values) }; + return insert; +} + + +// ── Subject under test ──────────────────────────────────────────────────────── +import { POST } from "./route"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function makeRequest(body: unknown, portfolioId = "10") { + const req = new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/approvals`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); + return { req, params: Promise.resolve({ portfolioId }) }; +} + +function setupHappyPath(approvalRowsAfterChange: Array<{ measureName: string; approvedByEmail: string }>) { + // 1. getServerSession + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + + // 2. getUserId select + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([{ id: 1n }]), + ); + + // 3. hasApproverCapability select + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([{ id: 1n }]), + ); + + // 4. upsert dealMeasureApprovals (one per change) + mockDbInsert.mockImplementationOnce(() => makeInsertChain()); + + // 5. insert dealMeasureApprovalEvents (one per change) + mockDbInsert.mockImplementationOnce(() => makeInsertChain()); + + // 6. post-change approvalRows select (no .limit — awaited at .where()) + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([], approvalRowsAfterChange), + ); + + // HubSpot syncs + syncMeasureApprovalsToHubSpotMock.mockResolvedValue(undefined); + syncMeasuresFieldToHubSpotMock.mockResolvedValue({ ok: true }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── +describe("POST /approvals — approved_measures HubSpot sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("syncs approved_measures field to HubSpot after an unapprove action", async () => { + setupHappyPath([ + // Only one measure remains approved after the unapprove + { measureName: "ASHP", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-1", measureName: "Solar PV", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + // Allow fire-and-forget promises to settle + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-1", + propName: "approved_measures", + measureNames: ["ASHP"], + }); + }); + + it("syncs approved_measures with empty list when all measures removed", async () => { + setupHappyPath([]); // nothing approved after removal + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-2", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-2", + propName: "approved_measures", + measureNames: [], + }); + }); + + it("syncs approved_measures when a new measure is approved", async () => { + setupHappyPath([ + { measureName: "ASHP", approvedByEmail: "approver@test.com" }, + { measureName: "Solar PV", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-3", measureName: "Solar PV", approved: true }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-3", + propName: "approved_measures", + measureNames: ["ASHP", "Solar PV"], + }); + }); + + it("also calls the audit-log sync (existing behaviour preserved)", async () => { + setupHappyPath([ + { measureName: "EWI", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-4", measureName: "EWI", approved: true }, + ], + }); + + await POST(req, { params }); + + await vi.waitFor(() => + expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalledWith( + expect.objectContaining({ + hubspotDealId: "deal-4", + approvedMeasures: [{ measureName: "EWI", approvedByEmail: "approver@test.com" }], + }), + ); + }); + + it("does not call HubSpot syncs when session is missing", async () => { + mockGetServerSession.mockResolvedValue(null); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-5", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(401); + expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled(); + }); + + it("does not call HubSpot syncs when user lacks approver capability", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "writer@test.com" } }); + + // getUserId + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n }])); + // hasApproverCapability → empty → no capability + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-6", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(403); + expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.ts index 85f2962..145ebb1 100644 --- a/src/app/api/portfolio/[portfolioId]/approvals/route.ts +++ b/src/app/api/portfolio/[portfolioId]/approvals/route.ts @@ -6,11 +6,15 @@ import { } from "@/app/db/schema/approvals"; import { portfolioCapabilities } from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync"; +import { + syncMeasureApprovalsToHubSpot, + syncMeasuresFieldToHubSpot, +} from "@/app/lib/hubspot/dealSync"; +import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure"; async function getRequestingUserId(email: string): Promise { const rows = await db @@ -102,7 +106,7 @@ export async function GET( .from(dealMeasureApprovalEvents) .leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy)) .where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds)) - .orderBy(dealMeasureApprovalEvents.actedAt); + .orderBy(desc(dealMeasureApprovalEvents.actedAt)); const events = eventRows.map((e) => ({ id: e.id.toString(), @@ -230,6 +234,19 @@ export async function POST( actedByEmail: session.user.email, actedAt: now, }); + + void syncMeasuresFieldToHubSpot({ + hubspotDealId: dealId, + propName: APPROVED_MEASURES_PROP, + measureNames: approvalRows.map((r) => r.measureName), + }).then((result) => { + if (!result.ok) { + console.error("[HubSpot] approved_measures sync failed", { + dealId, + error: result.error, + }); + } + }); } return NextResponse.json({ success: true }); diff --git a/src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts new file mode 100644 index 0000000..c2f80cf --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { z } from "zod"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { portfolioCapabilities } from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { and, eq } from "drizzle-orm"; +import { bulkApprove } from "@/app/lib/bulkApprove"; + +const bodySchema = z.object({ + changes: z + .array( + z.object({ + hubspotDealId: z.string().min(1), + measureName: z.string().min(1), + approved: z.boolean(), + }), + ) + .min(1, "changes must not be empty"), +}); + +/** + * POST /api/portfolio/[portfolioId]/bulk-approvals + * + * Approver-only. Applies all approve/unapprove changes in a single atomic + * DB transaction. If any change fails the entire batch is rolled back. + * + * Body: { changes: [{ hubspotDealId, measureName, approved }] } + * Response: 200 { ok: true, hubspotSync: "ok" | "failed" } | 400/401/403 + */ +export async function POST( + req: 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; + + const userRow = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, session.user.email)) + .limit(1); + + if (!userRow[0]) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const capabilityRows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), + eq(portfolioCapabilities.userId, userRow[0].id), + ), + ); + + if (!capabilityRows.some((r) => r.capability === "approver")) { + return NextResponse.json({ error: "Approver capability required" }, { status: 403 }); + } + + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + const result = await bulkApprove({ + changes: body.changes, + userId: userRow[0].id, + actedByEmail: session.user.email, + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 400 }); + } + + return NextResponse.json({ ok: true, hubspotSync: result.hubspotSync, hubspotError: result.hubspotError }); +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-instructed-measures/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-instructed-measures/route.ts new file mode 100644 index 0000000..3b02b66 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-instructed-measures/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { z } from "zod"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { and, eq } from "drizzle-orm"; +import { bulkInstructDeals } from "@/app/lib/bulkInstructDeals"; +import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; + +const bodySchema = z.object({ + deals: z + .array( + z.object({ + dealId: z.string().min(1), + measureNames: z.array(z.string().min(1)).min(1), + }), + ) + .min(1, "deals must not be empty"), + notes: z.string().optional(), +}); + +/** + * POST /api/portfolio/[portfolioId]/bulk-instructed-measures + * + * Approver-only. Instructs the given measures on each listed deal in a single + * atomic DB transaction. If any deal/measure fails the entire batch rolls back. + * + * Body: { deals: [{ dealId, measureNames }], notes? } + * Response: 200 { ok: true, hubspotSync } | 400/401/403 + */ +export async function POST( + req: 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; + + const userRow = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, session.user.email)) + .limit(1); + + if (!userRow[0]) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const portfolioUserRow = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, BigInt(portfolioId)), + eq(portfolioUsers.userId, userRow[0].id), + ), + ) + .limit(1); + + if (!portfolioUserRow[0]?.role) { + return NextResponse.json({ error: "No portfolio access" }, { status: 403 }); + } + + const capabilityRows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), + eq(portfolioCapabilities.userId, userRow[0].id), + ), + ); + + if (!capabilityRows.some((r) => r.capability === "approver")) { + return NextResponse.json({ error: "Approver capability required" }, { status: 403 }); + } + + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + for (const deal of body.deals) { + for (const name of deal.measureNames) { + if (!(MEASURE_NAMES as ReadonlyArray).includes(name)) { + return NextResponse.json({ error: `Unknown measure: ${name}` }, { status: 400 }); + } + } + } + + const result = await bulkInstructDeals({ + deals: body.deals, + userId: userRow[0].id, + notes: body.notes, + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 400 }); + } + + return NextResponse.json({ ok: true, hubspotSync: result.hubspotSync, hubspotError: result.hubspotError }); +} diff --git a/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts b/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts index ff7a74d..533f228 100644 --- a/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts +++ b/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts @@ -10,12 +10,14 @@ import { portfolioUsers, } from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; -import { instructMeasure } from "@/app/lib/instructMeasure"; +import { instructMeasures } from "@/app/lib/instructMeasure"; import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; const postSchema = z.object({ dealId: z.string().min(1, "dealId is required"), - measureName: z.string().min(1, "measureName is required"), + measureNames: z + .array(z.string().min(1)) + .min(1, "measureNames must not be empty"), }); /** @@ -27,10 +29,10 @@ const postSchema = z.object({ * pushes back to HubSpot. See `instructMeasure` for the full contract. * * Body: - * { dealId: string, measureName: string } + * { dealId: string, measureNames: string[] } * * Response: - * 200 { ok: true, hubspotSync: "ok" | "failed", autoPopulatedProposed: boolean, hubspotError? } + * 200 { ok: true, hubspotSync: "ok" | "failed", hubspotError? } * 400 { ok: false, error } * 401 / 403 / 404 on auth/role/user errors. */ @@ -60,15 +62,16 @@ export async function POST( ); } - const { dealId, measureName } = parsed.data; + const { dealId, measureNames } = parsed.data; - // Validate against the canonical catalogue up-front so the route returns - // a clean 400 rather than relying on the service-level check. - if (!(MEASURE_NAMES as ReadonlyArray).includes(measureName)) { - return NextResponse.json( - { error: `Unknown measure: ${measureName}` }, - { status: 400 }, - ); + // Validate all names against the canonical catalogue up-front. + for (const name of measureNames) { + if (!(MEASURE_NAMES as ReadonlyArray).includes(name)) { + return NextResponse.json( + { error: `Unknown measure: ${name}` }, + { status: 400 }, + ); + } } const userRow = await db @@ -119,9 +122,9 @@ export async function POST( } try { - const result = await instructMeasure({ + const result = await instructMeasures({ dealId, - measureName, + measureNames, userId: userRow[0].id, }); diff --git a/src/app/api/portfolio/[portfolioId]/organisation/route.test.ts b/src/app/api/portfolio/[portfolioId]/organisation/route.test.ts new file mode 100644 index 0000000..a59d9b2 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/organisation/route.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── +const { + mockGetServerSession, + mockDbSelect, + mockDbInsert, + mockDbDelete, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), + mockDbDelete: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} })); +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })), +})); +vi.mock("@/app/db/schema/portfolio_organisation", () => ({ + portfolioOrganisation: { + portfolioId: {}, + organisationId: {}, + id: {}, + }, +})); +vi.mock("@/app/db/schema/organisation", () => ({ + organisation: { id: {}, name: {}, hubspotCompanyId: {} }, +})); +vi.mock("@/app/db/db", () => ({ + db: { + get select() { return mockDbSelect; }, + get insert() { return mockDbInsert; }, + get delete() { return mockDbDelete; }, + }, +})); + +// ── Chain builders ──────────────────────────────────────────────────────────── +function makeSelectChain(rows: unknown[]) { + const self: Record = {}; + self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject); + self["from"] = vi.fn(() => self); + self["innerJoin"] = vi.fn(() => self); + self["leftJoin"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(rows)); + return self; +} + +function makeInsertChain() { + const self: Record = {}; + self["values"] = vi.fn(() => Promise.resolve([])); + return self; +} + +function makeDeleteChain() { + const self: Record = {}; + self["where"] = vi.fn(() => Promise.resolve([])); + return self; +} + +function makeParams(portfolioId = "42") { + return Promise.resolve({ portfolioId }); +} + +function makeRequest(method: string, body?: unknown, portfolioId = "42") { + return new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/organisation`, + { + method, + ...(body ? { body: JSON.stringify(body), headers: { "content-type": "application/json" } } : {}), + }, + ); +} + +// ── Subject under test ──────────────────────────────────────────────────────── +import { GET, POST, DELETE } from "./route"; + +describe("GET /portfolio/:id/organisation", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns empty array when no orgs linked", async () => { + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + const res = await GET(makeRequest("GET"), { params: makeParams() }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toEqual([]); + }); + + it("returns all linked orgs as array", async () => { + const orgs = [ + { id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" }, + { id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" }, + ]; + mockDbSelect.mockImplementationOnce(() => makeSelectChain(orgs)); + const res = await GET(makeRequest("GET"), { params: makeParams() }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toHaveLength(2); + expect(json[0].name).toBe("Alpha Housing"); + expect(json[1].name).toBe("Beta Council"); + }); +}); + +describe("POST /portfolio/:id/organisation", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns 403 for non-Domna user", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } }); + const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() }); + expect(res.status).toBe(403); + }); + + it("returns 400 when organisationId missing", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + const res = await POST(makeRequest("POST", {}), { params: makeParams() }); + expect(res.status).toBe(400); + }); + + it("returns 409 when org already linked to this portfolio", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + // existing link found + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: "link-1" }])); + const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() }); + expect(res.status).toBe(409); + }); + + it("adds org without removing existing links", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + // no existing link for this org + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + const insertChain = makeInsertChain(); + mockDbInsert.mockImplementationOnce(() => insertChain); + // return updated list + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([ + { id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" }, + { id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" }, + ]), + ); + const res = await POST(makeRequest("POST", { organisationId: "org-2" }), { params: makeParams() }); + expect(res.status).toBe(200); + // insert called — no delete called + expect(mockDbDelete).not.toHaveBeenCalled(); + expect(insertChain.values).toHaveBeenCalled(); + const json = await res.json(); + expect(json).toHaveLength(2); + }); +}); + +describe("DELETE /portfolio/:id/organisation", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns 403 for non-Domna user", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } }); + const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() }); + expect(res.status).toBe(403); + }); + + it("removes the specific org link", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + const deleteChain = makeDeleteChain(); + mockDbDelete.mockImplementationOnce(() => deleteChain); + const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() }); + expect(res.status).toBe(200); + expect(deleteChain.where).toHaveBeenCalled(); + const json = await res.json(); + expect(json.success).toBe(true); + }); + + it("returns 400 when organisationId missing", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } }); + const res = await DELETE(makeRequest("DELETE", {}), { params: makeParams() }); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/organisation/route.ts b/src/app/api/portfolio/[portfolioId]/organisation/route.ts index f6bf22f..02f985f 100644 --- a/src/app/api/portfolio/[portfolioId]/organisation/route.ts +++ b/src/app/api/portfolio/[portfolioId]/organisation/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db } from "@/app/db/db"; import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation"; import { organisation } from "@/app/db/schema/organisation"; @@ -10,14 +10,8 @@ function isDomnaUser(email: string | null | undefined): boolean { return !!email?.endsWith("@domna.homes"); } -// GET — fetch the current linked organisation for this portfolio -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ portfolioId: string }> }, -) { - const { portfolioId } = await params; - - const rows = await db +function linkedOrgsQuery(portfolioId: string) { + return db .select({ id: organisation.id, name: organisation.name, @@ -25,13 +19,20 @@ export async function GET( }) .from(portfolioOrganisation) .innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id)) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))) - .limit(1); - - return NextResponse.json(rows[0] ?? null); + .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))); } -// POST — connect an organisation to this portfolio (Domna only) +// GET — fetch all linked organisations for this portfolio +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await params; + const rows = await linkedOrgsQuery(portfolioId); + return NextResponse.json(rows); +} + +// POST — add an organisation link (Domna only, rejects duplicates) export async function POST( req: NextRequest, { params }: { params: Promise<{ portfolioId: string }> }, @@ -43,40 +44,40 @@ export async function POST( const { portfolioId } = await params; const body = await req.json(); - const { organisationId } = body as { organisationId: string }; + const { organisationId } = body as { organisationId?: string }; if (!organisationId) { return NextResponse.json({ error: "organisationId required" }, { status: 400 }); } - // Upsert: delete any existing link then insert fresh - await db - .delete(portfolioOrganisation) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))); + // Reject if this org is already linked to this portfolio + const existing = await db + .select({ id: portfolioOrganisation.id }) + .from(portfolioOrganisation) + .where( + and( + eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)), + eq(portfolioOrganisation.organisationId, organisationId), + ), + ) + .limit(1); + + if (existing.length > 0) { + return NextResponse.json({ error: "Organisation already linked" }, { status: 409 }); + } await db.insert(portfolioOrganisation).values({ portfolioId: BigInt(portfolioId), organisationId, }); - // Return the newly linked org - const rows = await db - .select({ - id: organisation.id, - name: organisation.name, - hubspotCompanyId: organisation.hubspotCompanyId, - }) - .from(portfolioOrganisation) - .innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id)) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))) - .limit(1); - - return NextResponse.json(rows[0] ?? null); + const rows = await linkedOrgsQuery(portfolioId); + return NextResponse.json(rows); } -// DELETE — disconnect the organisation from this portfolio (Domna only) +// DELETE — remove a specific organisation link (Domna only) export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ portfolioId: string }> }, ) { const session = await getServerSession(AuthOptions); @@ -85,10 +86,21 @@ export async function DELETE( } const { portfolioId } = await params; + const body = await req.json().catch(() => ({})); + const { organisationId } = body as { organisationId?: string }; + + if (!organisationId) { + return NextResponse.json({ error: "organisationId required" }, { status: 400 }); + } await db .delete(portfolioOrganisation) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))); + .where( + and( + eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)), + eq(portfolioOrganisation.organisationId, organisationId), + ), + ); return NextResponse.json({ success: true }); } diff --git a/src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.test.ts b/src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.test.ts new file mode 100644 index 0000000..4be1363 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const { + mockGetServerSession, + mockUpdatePibiRequest, + mockDeletePibiRequest, + mockDbSelect, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + mockUpdatePibiRequest: vi.fn(), + mockDeletePibiRequest: vi.fn(), + mockDbSelect: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} })); +vi.mock("@/app/lib/updatePibiRequest", () => ({ + updatePibiRequest: mockUpdatePibiRequest, + PIBI_ORDERED_TEXT_PROP: "measures_for_pibi_ordered_text", +})); +vi.mock("@/app/lib/deletePibiRequest", () => ({ + deletePibiRequest: mockDeletePibiRequest, +})); +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), +})); +vi.mock("@/app/db/schema/portfolio", () => ({ + portfolioUsers: { portfolioId: {}, userId: {}, role: {} }, + portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} }, +})); +vi.mock("@/app/db/schema/users", () => ({ user: { id: {}, email: {} } })); +vi.mock("@/app/db/db", () => ({ + db: { get select() { return mockDbSelect; } }, +})); + +function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) { + const self: Record = {}; + self["then"] = (_resolve: (v: unknown) => unknown, _reject: (e: unknown) => unknown) => + Promise.resolve(directResult).then(_resolve, _reject); + self["from"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(limitResult)); + return self; +} + +function mockApproverAuth(userId = 2n) { + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: userId }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }])); +} + +function makeParams(portfolioId = "5", id = "7") { + return Promise.resolve({ portfolioId, id }); +} + +import { PATCH, DELETE } from "./route"; + +describe("PATCH /pibi-requests/[id]", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpdatePibiRequest.mockResolvedValue({ ok: true, hubspotSync: "ok" }); + }); + + it("returns 401 when unauthenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", { + method: "PATCH", + body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }), + headers: { "content-type": "application/json" }, + }); + const res = await PATCH(req, { params: makeParams() }); + expect(res.status).toBe(401); + }); + + it("returns 403 when not approver", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "user@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [])); + + const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", { + method: "PATCH", + body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }), + headers: { "content-type": "application/json" }, + }); + const res = await PATCH(req, { params: makeParams() }); + expect(res.status).toBe(403); + }); + + it("updates PIBI and returns ok=true", async () => { + mockApproverAuth(); + const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", { + method: "PATCH", + body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }), + headers: { "content-type": "application/json" }, + }); + const res = await PATCH(req, { params: makeParams() }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.hubspotSync).toBe("ok"); + expect(mockUpdatePibiRequest).toHaveBeenCalledWith( + expect.objectContaining({ id: 7n, dealId: "deal-1" }), + ); + }); +}); + +describe("DELETE /pibi-requests/[id]", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDeletePibiRequest.mockResolvedValue({ ok: true, hubspotSync: "ok" }); + }); + + it("returns 401 when unauthenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7?dealId=deal-1", { + method: "DELETE", + }); + const res = await DELETE(req, { params: makeParams() }); + expect(res.status).toBe(401); + }); + + it("deletes PIBI and returns ok=true", async () => { + mockApproverAuth(); + const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7?dealId=deal-1", { + method: "DELETE", + }); + const res = await DELETE(req, { params: makeParams() }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(mockDeletePibiRequest).toHaveBeenCalledWith( + expect.objectContaining({ id: 7n, dealId: "deal-1" }), + ); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.ts b/src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.ts new file mode 100644 index 0000000..4481804 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { updatePibiRequest } from "@/app/lib/updatePibiRequest"; +import { deletePibiRequest } from "@/app/lib/deletePibiRequest"; + +const patchSchema = z.object({ + dealId: z.string().min(1, "dealId is required"), + measureName: z.string().min(1).optional(), + orderedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().nullable().optional(), +}); + +async function resolveApprover( + email: string, + portfolioId: string, +): Promise< + | { ok: true; userId: bigint } + | { ok: false; status: 401 | 403 | 404; error: string } +> { + const userRow = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email)) + .limit(1); + + if (!userRow[0]) return { ok: false, status: 404, error: "User not found" }; + + const portfolioUserRow = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, BigInt(portfolioId)), + eq(portfolioUsers.userId, userRow[0].id), + ), + ) + .limit(1); + + if (!portfolioUserRow[0]?.role) { + return { ok: false, status: 403, error: "No portfolio access" }; + } + + const capabilityRows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), + eq(portfolioCapabilities.userId, userRow[0].id), + ), + ); + + if (!capabilityRows.map((r) => r.capability).includes("approver")) { + return { ok: false, status: 403, error: "Approver capability required" }; + } + + return { ok: true, userId: userRow[0].id }; +} + +/** + * PATCH /api/portfolio/[portfolioId]/pibi-requests/[id] + * + * Approver-only. Updates a PIBI request row. + * Body: { dealId: string, measureName?: string, orderedAt?: string, completedAt?: string | null } + */ +export async function PATCH( + req: NextRequest, + props: { params: Promise<{ portfolioId: string; id: string }> }, +) { + const { portfolioId, id } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = patchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const auth = await resolveApprover(session.user.email, portfolioId); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const { dealId, measureName, orderedAt, completedAt } = parsed.data; + + const result = await updatePibiRequest({ + id: BigInt(id), + dealId, + updates: { + ...(measureName !== undefined && { measureName }), + ...(orderedAt !== undefined && { orderedAt: new Date(orderedAt) }), + ...(completedAt !== undefined && { completedAt: completedAt ? new Date(completedAt) : null }), + }, + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 400 }); + } + + return NextResponse.json({ + ok: true, + hubspotSync: result.hubspotSync, + hubspotError: result.hubspotError, + }); +} + +/** + * DELETE /api/portfolio/[portfolioId]/pibi-requests/[id]?dealId=... + * + * Approver-only. Deletes a PIBI request row and re-syncs HubSpot. + */ +export async function DELETE( + req: NextRequest, + props: { params: Promise<{ portfolioId: string; id: string }> }, +) { + const { portfolioId, id } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const dealId = req.nextUrl.searchParams.get("dealId"); + if (!dealId) { + return NextResponse.json({ error: "dealId query param is required" }, { status: 400 }); + } + + const auth = await resolveApprover(session.user.email, portfolioId); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const result = await deletePibiRequest({ id: BigInt(id), dealId }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 400 }); + } + + return NextResponse.json({ + ok: true, + hubspotSync: result.hubspotSync, + hubspotError: result.hubspotError, + }); +} diff --git a/src/app/api/portfolio/[portfolioId]/pibi-requests/route.test.ts b/src/app/api/portfolio/[portfolioId]/pibi-requests/route.test.ts new file mode 100644 index 0000000..f1f3fd0 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/pibi-requests/route.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── +const { + mockGetServerSession, + mockCreatePibiRequests, + mockDbSelect, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + mockCreatePibiRequests: vi.fn(), + mockDbSelect: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} })); +vi.mock("@/app/lib/createPibiRequests", () => ({ + createPibiRequests: mockCreatePibiRequests, + PIBI_ORDERED_TEXT_PROP: "measures_for_pibi_ordered_text", +})); +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + desc: vi.fn((col: unknown) => ({ $desc: col })), +})); +vi.mock("@/app/db/schema/pibi_requests", () => ({ + pibiRequests: { + id: {}, hubspotDealId: {}, portfolioId: {}, measureName: {}, + orderedAt: {}, completedAt: {}, createdByUserId: {}, pushedAt: {}, + }, +})); +vi.mock("@/app/db/schema/portfolio", () => ({ + portfolioUsers: { portfolioId: {}, userId: {}, role: {} }, + portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} }, +})); +vi.mock("@/app/db/schema/users", () => ({ user: { id: {}, email: {} } })); +vi.mock("@/app/db/db", () => ({ + db: { get select() { return mockDbSelect; } }, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) { + const self: Record = {}; + self["then"] = (_resolve: (v: unknown) => unknown, _reject: (e: unknown) => unknown) => + Promise.resolve(directResult).then(_resolve, _reject); + self["from"] = vi.fn(() => self); + self["innerJoin"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["orderBy"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(limitResult)); + return self; +} + +function mockApproverAuth(userId = 2n) { + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: userId, email: "approver@test.com" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }])); +} + +function makeRequest(body: unknown, portfolioId = "5") { + const req = new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/pibi-requests`, + { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }, + ); + return { req, params: Promise.resolve({ portfolioId }) }; +} + +function makeGetRequest(dealId: string, portfolioId = "5") { + const req = new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/pibi-requests?dealId=${dealId}`, + ); + return { req, params: Promise.resolve({ portfolioId }) }; +} + +import { GET, POST } from "./route"; + +describe("GET /pibi-requests", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns 401 when unauthenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + const { req, params } = makeGetRequest("deal-1"); + const res = await GET(req, { params }); + expect(res.status).toBe(401); + }); + + it("returns 400 when dealId missing", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "x@test.com" } }); + const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests"); + const res = await GET(req, { params: Promise.resolve({ portfolioId: "5" }) }); + expect(res.status).toBe(400); + }); + + it("returns pibi requests for deal", async () => { + const orderedAt = new Date("2026-05-06T10:00:00Z"); + mockGetServerSession.mockResolvedValue({ user: { email: "x@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([], [ + { id: 1n, measureName: "CWI", orderedAt, completedAt: null }, + { id: 2n, measureName: "Loft insulation", orderedAt, completedAt: null }, + ]) + ); + + const { req, params } = makeGetRequest("deal-1"); + const res = await GET(req, { params }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.pibiRequests).toHaveLength(2); + }); +}); + +describe("POST /pibi-requests", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreatePibiRequests.mockResolvedValue({ ok: true, insertedRowIds: [1n], hubspotSync: "ok" }); + }); + + it("returns 401 when unauthenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI"] }); + const res = await POST(req, { params }); + expect(res.status).toBe(401); + }); + + it("returns 403 when user lacks approver capability", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "write@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [])); + + const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI"] }); + const res = await POST(req, { params }); + expect(res.status).toBe(403); + }); + + it("returns 400 when measureNames is empty", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + const { req, params } = makeRequest({ dealId: "deal-1", measureNames: [] }); + const res = await POST(req, { params }); + expect(res.status).toBe(400); + }); + + it("creates PIBIs and returns ok=true with insertedCount", async () => { + mockApproverAuth(); + + const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI", "ASHP"] }); + const res = await POST(req, { params }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.hubspotSync).toBe("ok"); + expect(json.insertedCount).toBe(1); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/pibi-requests/route.ts b/src/app/api/portfolio/[portfolioId]/pibi-requests/route.ts new file mode 100644 index 0000000..d08bf93 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/pibi-requests/route.ts @@ -0,0 +1,189 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { and, eq, desc } from "drizzle-orm"; +import { z } from "zod"; + +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { pibiRequests } from "@/app/db/schema/pibi_requests"; +import { createPibiRequests } from "@/app/lib/createPibiRequests"; + +const postSchema = z.object({ + dealId: z.string().min(1, "dealId is required"), + measureNames: z.array(z.string().min(1)).min(1, "at least one measure required"), + orderedAt: z.string().datetime().optional(), +}); + +async function resolveApprover( + email: string, + portfolioId: string, +): Promise< + | { ok: true; userId: bigint } + | { ok: false; status: 401 | 403 | 404; error: string } +> { + const userRow = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email)) + .limit(1); + + if (!userRow[0]) return { ok: false, status: 404, error: "User not found" }; + + const portfolioUserRow = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, BigInt(portfolioId)), + eq(portfolioUsers.userId, userRow[0].id), + ), + ) + .limit(1); + + if (!portfolioUserRow[0]?.role) { + return { ok: false, status: 403, error: "No portfolio access" }; + } + + const capabilityRows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), + eq(portfolioCapabilities.userId, userRow[0].id), + ), + ); + + if (!capabilityRows.map((r) => r.capability).includes("approver")) { + return { ok: false, status: 403, error: "Approver capability required" }; + } + + return { ok: true, userId: userRow[0].id }; +} + +/** + * GET /api/portfolio/[portfolioId]/pibi-requests?dealId=... + * + * Returns all PIBI requests for a deal, ordered by orderedAt desc. + * Response: { pibiRequests: PibiRequestRow[] } + */ +export async function GET( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const dealId = req.nextUrl.searchParams.get("dealId"); + if (!dealId) { + return NextResponse.json({ error: "dealId query param is required" }, { status: 400 }); + } + + const userRow = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, session.user.email)) + .limit(1); + + if (!userRow[0]) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const portfolioUserRow = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, BigInt(portfolioId)), + eq(portfolioUsers.userId, userRow[0].id), + ), + ) + .limit(1); + + if (!portfolioUserRow[0]?.role) { + return NextResponse.json({ error: "No portfolio access" }, { status: 403 }); + } + + const rows = await db + .select({ + id: pibiRequests.id, + measureName: pibiRequests.measureName, + orderedAt: pibiRequests.orderedAt, + completedAt: pibiRequests.completedAt, + }) + .from(pibiRequests) + .where(eq(pibiRequests.hubspotDealId, dealId)) + .orderBy(desc(pibiRequests.orderedAt)); + + return NextResponse.json({ + pibiRequests: rows.map((r) => ({ + id: String(r.id), + measureName: r.measureName, + orderedAt: r.orderedAt, + completedAt: r.completedAt, + })), + }); +} + +/** + * POST /api/portfolio/[portfolioId]/pibi-requests + * + * Approver-only. Creates one pibi_request row per measure in the batch. + * Body: { dealId: string, measureNames: string[], orderedAt?: string (ISO) } + * Response: { ok: true, insertedCount: number, hubspotSync: "ok" | "failed" } + */ +export async function POST( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = postSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const auth = await resolveApprover(session.user.email, portfolioId); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const { dealId, measureNames, orderedAt } = parsed.data; + + const result = await createPibiRequests({ + dealId, + portfolioId: BigInt(portfolioId), + measureNames, + orderedAt: orderedAt ? new Date(orderedAt) : undefined, + userId: auth.userId, + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 400 }); + } + + return NextResponse.json({ + ok: true, + insertedCount: result.insertedRowIds.length, + hubspotSync: result.hubspotSync, + hubspotError: result.hubspotError, + }); +} diff --git a/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts b/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts new file mode 100644 index 0000000..8db83b0 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── +const { + mockGetServerSession, + mockSyncSurveyRequestToHubSpot, + mockDbSelect, + mockDbInsert, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + mockSyncSurveyRequestToHubSpot: vi.fn(), + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ + AuthOptions: {}, +})); +vi.mock("@/app/lib/hubspot/dealSync", () => ({ + syncSurveyRequestToHubSpot: mockSyncSurveyRequestToHubSpot, +})); +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + desc: vi.fn((col: unknown) => ({ $desc: col })), +})); +vi.mock("@/app/db/schema/survey_requests", () => ({ + surveyRequests: { + id: {}, hubspotDealId: {}, portfolioId: {}, notes: {}, + surveyType: {}, status: {}, requestedBy: {}, requestedAt: {}, fulfilledAt: {}, + }, +})); +vi.mock("@/app/db/schema/portfolio", () => ({ + portfolioUsers: { portfolioId: {}, userId: {}, role: {} }, + portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} }, +})); +vi.mock("@/app/db/schema/users", () => ({ + user: { id: {}, email: {} }, +})); +vi.mock("@/app/db/db", () => ({ + db: { + get select() { return mockDbSelect; }, + get insert() { return mockDbInsert; }, + }, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) { + const self: Record = {}; + self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) => + Promise.resolve(directResult).then(resolve, reject); + self["from"] = vi.fn(() => self); + self["innerJoin"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["orderBy"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(limitResult)); + return self; +} + +function makeInsertChain(returningResult: unknown[] = []) { + const returning = vi.fn(() => Promise.resolve(returningResult)); + const values = vi.fn(() => ({ returning })); + return { values }; +} + +function makeRequest(body: unknown, portfolioId = "5") { + const req = new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/survey-requests`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); + return { req, params: Promise.resolve({ portfolioId }) }; +} + +// ── Subject under test ──────────────────────────────────────────────────────── +import { POST } from "./route"; + +describe("POST /survey-requests", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: true }); + }); + + it("returns 401 when unauthenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(401); + }); + + it("returns 403 when user lacks approver capability", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "write@test.com" } }); + // user lookup + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n, email: "write@test.com" }])); + // portfolio role check + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }])); + // capability check — no rows (directResult), so not an approver + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [])); + + const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(403); + const json = await res.json(); + expect(json.error).toMatch(/approver/i); + }); + + it("returns 409 when a pending request already exists for the deal", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + // capability rows come back via directResult (no .limit() on that query) + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }])); + // pending check — returns a pending row + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n, status: "pending" }])); + + const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(409); + const json = await res.json(); + expect(json.error).toMatch(/pending/i); + }); + + it("creates the request with surveyType and syncs to HubSpot", async () => { + const insertedAt = new Date("2026-05-06T10:00:00.000Z"); + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }])); + // no pending request + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + // insert returning + mockDbInsert.mockImplementationOnce(() => + makeInsertChain([{ id: 42n, requestedAt: insertedAt }]) + ); + + const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.id).toBe("42"); + expect(json.hubspotSync).toBe("ok"); + + expect(mockSyncSurveyRequestToHubSpot).toHaveBeenCalledWith({ + hubspotDealId: "deal-abc", + surveyType: "technical_building_survey", + requestedAt: insertedAt, + }); + }); + + it("returns hubspotSync: failed but still 200 when HubSpot fails", async () => { + const insertedAt = new Date(); + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + mockDbInsert.mockImplementationOnce(() => + makeInsertChain([{ id: 43n, requestedAt: insertedAt }]) + ); + mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: false, error: "HubSpot sync failed" }); + + const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.hubspotSync).toBe("failed"); + expect(json.hubspotError).toBe("HubSpot sync failed"); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts index 8190fa9..7b2c02b 100644 --- a/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts +++ b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts @@ -1,7 +1,7 @@ import { db } from "@/app/db/db"; import { NextRequest, NextResponse } from "next/server"; import { surveyRequests } from "@/app/db/schema/survey_requests"; -import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { portfolioUsers, portfolioCapabilities } from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; import { and, eq, desc } from "drizzle-orm"; import { z } from "zod"; @@ -9,8 +9,6 @@ import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync"; -const WRITE_ROLES = ["creator", "admin", "write"] as const; - async function getRequestingUser(email: string) { const rows = await db .select({ id: user.id, email: user.email }) @@ -20,7 +18,7 @@ async function getRequestingUser(email: string) { return rows[0] ?? null; } -async function getUserRole(portfolioId: bigint, userId: bigint) { +async function hasPortfolioRole(portfolioId: bigint, userId: bigint) { const rows = await db .select({ role: portfolioUsers.role }) .from(portfolioUsers) @@ -31,11 +29,38 @@ async function getUserRole(portfolioId: bigint, userId: bigint) { ), ) .limit(1); - return rows[0]?.role ?? null; + return !!rows[0]?.role; +} + +async function hasApproverCapability(portfolioId: bigint, userId: bigint) { + const rows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, portfolioId), + eq(portfolioCapabilities.userId, userId), + ), + ); + return rows.map((r) => r.capability).includes("approver"); +} + +async function getPendingRequest(hubspotDealId: string, portfolioId: bigint) { + const rows = await db + .select({ id: surveyRequests.id, status: surveyRequests.status }) + .from(surveyRequests) + .where( + and( + eq(surveyRequests.hubspotDealId, hubspotDealId), + eq(surveyRequests.portfolioId, portfolioId), + eq(surveyRequests.status, "pending"), + ), + ) + .limit(1); + return rows[0] ?? null; } // GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx -// Returns all survey requests for a deal, most recent first. export async function GET( req: NextRequest, props: { params: Promise<{ portfolioId: string }> }, @@ -57,7 +82,7 @@ export async function GET( .select({ id: surveyRequests.id, hubspotDealId: surveyRequests.hubspotDealId, - notes: surveyRequests.notes, + surveyType: surveyRequests.surveyType, status: surveyRequests.status, requestedAt: surveyRequests.requestedAt, fulfilledAt: surveyRequests.fulfilledAt, @@ -77,7 +102,7 @@ export async function GET( const requests = rows.map((r) => ({ id: String(r.id), hubspotDealId: r.hubspotDealId, - notes: r.notes, + surveyType: r.surveyType ?? null, status: r.status, requestedByEmail: r.requestedByEmail, requestedAt: r.requestedAt?.toISOString() ?? null, @@ -93,11 +118,11 @@ export async function GET( const postSchema = z.object({ hubspotDealId: z.string().min(1), - notes: z.string().min(1, "Notes are required"), + surveyType: z.string().min(1), }); // POST /api/portfolio/[portfolioId]/survey-requests -// Submit a new survey request — requires write+ role. +// Submit a new survey request — requires approver capability. export async function POST( req: NextRequest, props: { params: Promise<{ portfolioId: string }> }, @@ -114,10 +139,17 @@ export async function POST( return NextResponse.json({ error: "User not found" }, { status: 401 }); } - const role = await getUserRole(BigInt(portfolioId), requestingUser.id); - if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) { + const pid = BigInt(portfolioId); + + const isMember = await hasPortfolioRole(pid, requestingUser.id); + if (!isMember) { + return NextResponse.json({ error: "No portfolio access" }, { status: 403 }); + } + + const isApprover = await hasApproverCapability(pid, requestingUser.id); + if (!isApprover) { return NextResponse.json( - { error: "Write access required to submit a survey request" }, + { error: "Approver capability required to submit a survey request" }, { status: 403 }, ); } @@ -134,24 +166,33 @@ export async function POST( return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } - const { hubspotDealId, notes } = parsed.data; + const { hubspotDealId, surveyType } = parsed.data; + + const existing = await getPendingRequest(hubspotDealId, pid); + if (existing) { + return NextResponse.json( + { error: "A pending survey request already exists for this deal" }, + { status: 409 }, + ); + } try { const [inserted] = await db .insert(surveyRequests) .values({ hubspotDealId, - portfolioId: BigInt(portfolioId), - notes, + portfolioId: pid, + notes: "", + surveyType, status: "pending", requestedBy: requestingUser.id, }) - .returning({ id: surveyRequests.id }); + .returning({ id: surveyRequests.id, requestedAt: surveyRequests.requestedAt }); const hubspotResult = await syncSurveyRequestToHubSpot({ hubspotDealId, - notes, - requestedByEmail: requestingUser.email, + surveyType, + requestedAt: inserted.requestedAt, }); return NextResponse.json({ diff --git a/src/app/db/migrations/0204_bizarre_black_bird.sql b/src/app/db/migrations/0204_bizarre_black_bird.sql new file mode 100644 index 0000000..1bed7a4 --- /dev/null +++ b/src/app/db/migrations/0204_bizarre_black_bird.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."file_source" ADD VALUE 'coordination_hub'; \ No newline at end of file diff --git a/src/app/db/migrations/meta/0204_snapshot.json b/src/app/db/migrations/meta/0204_snapshot.json new file mode 100644 index 0000000..23ba42a --- /dev/null +++ b/src/app/db/migrations/meta/0204_snapshot.json @@ -0,0 +1,9429 @@ +{ + "id": "60d8c3af-56f2-42ff-a812-9e87996c24d4", + "prevId": "747b070d-3fb6-4f16-b70d-3d04e3a980fb", + "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 + }, + "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_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 + }, + "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 + }, + "block_reference": { + "name": "block_reference", + "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 + }, + "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_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.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 + }, + "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_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 + }, + "opening_type": { + "name": "opening_type", + "type": "text", + "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.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": {}, + "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.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.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": "text", + "primaryKey": false, + "notNull": true + }, + "wall_insulation_type": { + "name": "wall_insulation_type", + "type": "text", + "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": "text", + "primaryKey": false, + "notNull": true + }, + "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": "text", + "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": "text", + "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": "text", + "primaryKey": false, + "notNull": false + }, + "roof_insulation_thickness": { + "name": "roof_insulation_thickness", + "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_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 + } + }, + "indexes": {}, + "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": {}, + "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 + } + }, + "indexes": {}, + "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": "text", + "primaryKey": false, + "notNull": true + }, + "heat_emitter_type": { + "name": "heat_emitter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emitter_temperature": { + "name": "emitter_temperature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "main_heating_control": { + "name": "main_heating_control", + "type": "text", + "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 + } + }, + "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_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 + }, + "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 + }, + "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_mains_gas": { + "name": "energy_mains_gas", + "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": "text", + "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 + }, + "heating_cylinder_size": { + "name": "heating_cylinder_size", + "type": "text", + "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": "text", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_insulation_type": { + "name": "heating_cylinder_insulation_type", + "type": "text", + "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": "text", + "primaryKey": false, + "notNull": false + }, + "heating_cylinder_insulation_thickness_mm": { + "name": "heating_cylinder_insulation_thickness_mm", + "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": "text", + "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 + } + }, + "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" + } + ], + "isUnique": true, + "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_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": "text", + "primaryKey": false, + "notNull": true + }, + "orientation": { + "name": "orientation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_type": { + "name": "window_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "glazing_type": { + "name": "glazing_type", + "type": "text", + "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": "boolean", + "primaryKey": false, + "notNull": true + }, + "window_location": { + "name": "window_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_wall_type": { + "name": "window_wall_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permanent_shutters_present": { + "name": "permanent_shutters_present", + "type": "boolean", + "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": "text", + "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 + } + }, + "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": {} + } + }, + "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_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": {} + } + }, + "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 + }, + "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_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": {} + } + }, + "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" + } + }, + "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'" + }, + "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.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 + } + }, + "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.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.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.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" + ] + }, + "public.file_source": { + "name": "file_source", + "schema": "public", + "values": [ + "pas hub", + "sharepoint", + "hubspot", + "ecmk", + "contractor", + "magic_plan", + "coordination_hub" + ] + }, + "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" + ] + }, + "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 a8e6825..3816d50 100644 --- a/src/app/db/migrations/meta/_journal.json +++ b/src/app/db/migrations/meta/_journal.json @@ -1429,6 +1429,13 @@ "when": 1778680271996, "tag": "0203_kind_spyke", "breakpoints": true + }, + { + "idx": 204, + "version": "7", + "when": 1779092260406, + "tag": "0204_bizarre_black_bird", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/db/schema/survey_requests.ts b/src/app/db/schema/survey_requests.ts index 8fbf6c4..55001d0 100644 --- a/src/app/db/schema/survey_requests.ts +++ b/src/app/db/schema/survey_requests.ts @@ -20,7 +20,6 @@ export const surveyRequests = pgTable( portfolioId: bigint("portfolio_id", { mode: "bigint" }) .notNull() .references(() => portfolio.id), - // Free-text notes from the requester describing what survey is needed. notes: text("notes").notNull(), surveyType: text("survey_type"), // 'pending' | 'fulfilled' diff --git a/src/app/db/schema/uploaded_files.ts b/src/app/db/schema/uploaded_files.ts index 7d18a5d..4020760 100644 --- a/src/app/db/schema/uploaded_files.ts +++ b/src/app/db/schema/uploaded_files.ts @@ -63,7 +63,8 @@ export const fileSource = pgEnum("file_source", [ "hubspot", "ecmk", "contractor", - "magic_plan" + "magic_plan", + "coordination_hub" ]); export const uploadedFiles = pgTable( diff --git a/src/app/lib/bulkApprove.test.ts b/src/app/lib/bulkApprove.test.ts new file mode 100644 index 0000000..81f034b --- /dev/null +++ b/src/app/lib/bulkApprove.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from "vitest"; +import { bulkApprove } from "./bulkApprove"; +import type { RunBulkApproveTx, SyncApprovalsForDeals } from "./bulkApprove"; + +const noopSync: SyncApprovalsForDeals = async () => ({ ok: true }); + +function makeSuccessTx(approvedByDeal: Record = {}): RunBulkApproveTx { + return async ({ changes }) => { + const dealIds = [...new Set(changes.map((c) => c.hubspotDealId))]; + const result: Record = {}; + for (const id of dealIds) { + result[id] = approvedByDeal[id] ?? []; + } + return { approvedMeasuresByDeal: result }; + }; +} + +describe("bulkApprove", () => { + it("returns ok:false when changes array is empty", async () => { + const result = await bulkApprove({ + changes: [], + userId: 1n, + deps: { runBulkApproveTx: makeSuccessTx(), syncApprovals: noopSync }, + }); + expect(result).toEqual({ ok: false, error: "changes must not be empty" }); + }); + + it("calls tx with all changes and returns ok:true on success", async () => { + const txSpy = vi.fn(makeSuccessTx({ "deal-1": ["Loft insulation"] })); + const result = await bulkApprove({ + changes: [{ hubspotDealId: "deal-1", measureName: "Loft insulation", approved: true }], + userId: 1n, + deps: { runBulkApproveTx: txSpy, syncApprovals: noopSync }, + }); + expect(result.ok).toBe(true); + expect(txSpy).toHaveBeenCalledOnce(); + expect(txSpy.mock.calls[0][0].changes).toHaveLength(1); + }); + + it("returns ok:false when tx throws (atomic rollback)", async () => { + const failingTx: RunBulkApproveTx = async () => { + throw new Error("DB constraint violated"); + }; + const result = await bulkApprove({ + changes: [{ hubspotDealId: "deal-1", measureName: "CWI", approved: true }], + userId: 1n, + deps: { runBulkApproveTx: failingTx, syncApprovals: noopSync }, + }); + expect(result).toMatchObject({ ok: false, error: "DB constraint violated" }); + }); + + it("returns ok:true with hubspotSync:'failed' when HubSpot sync fails", async () => { + const failingSync: SyncApprovalsForDeals = async () => ({ + ok: false, + error: "HubSpot timeout", + }); + const result = await bulkApprove({ + changes: [{ hubspotDealId: "deal-1", measureName: "CWI", approved: true }], + userId: 1n, + deps: { runBulkApproveTx: makeSuccessTx(), syncApprovals: failingSync }, + }); + expect(result).toMatchObject({ ok: true, hubspotSync: "failed", hubspotError: "HubSpot timeout" }); + }); + + it("calls sync once per affected deal", async () => { + const syncSpy = vi.fn(noopSync); + await bulkApprove({ + changes: [ + { hubspotDealId: "deal-1", measureName: "CWI", approved: true }, + { hubspotDealId: "deal-2", measureName: "ASHP", approved: true }, + { hubspotDealId: "deal-1", measureName: "EWI", approved: true }, + ], + userId: 1n, + deps: { + runBulkApproveTx: makeSuccessTx({ + "deal-1": ["CWI", "EWI"], + "deal-2": ["ASHP"], + }), + syncApprovals: syncSpy, + }, + }); + expect(syncSpy).toHaveBeenCalledOnce(); + const call = syncSpy.mock.calls[0][0]; + expect(Object.keys(call.approvedMeasuresByDeal).sort()).toEqual(["deal-1", "deal-2"]); + }); +}); diff --git a/src/app/lib/bulkApprove.ts b/src/app/lib/bulkApprove.ts new file mode 100644 index 0000000..7d925c5 --- /dev/null +++ b/src/app/lib/bulkApprove.ts @@ -0,0 +1,156 @@ +import { db } from "@/app/db/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { + dealMeasureApprovals, + dealMeasureApprovalEvents, +} from "@/app/db/schema/approvals"; +import { user } from "@/app/db/schema/users"; +import { + syncMeasureApprovalsToHubSpot, + syncMeasuresFieldToHubSpot, +} from "@/app/lib/hubspot/dealSync"; +import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure"; + +export interface BulkApproveChange { + hubspotDealId: string; + measureName: string; + approved: boolean; +} + +export interface BulkApproveTxResult { + approvedMeasuresByDeal: Record; +} + +export type RunBulkApproveTx = (params: { + changes: BulkApproveChange[]; + userId: bigint; +}) => Promise; + +export type SyncApprovalsForDeals = (params: { + approvedMeasuresByDeal: Record; + actedByEmail: string; + actedAt: Date; +}) => Promise<{ ok: true } | { ok: false; error: string }>; + +export type BulkApproveResult = + | { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string } + | { ok: false; error: string }; + +export interface BulkApproveInput { + changes: BulkApproveChange[]; + userId: bigint; + actedByEmail?: string; + deps?: { + runBulkApproveTx?: RunBulkApproveTx; + syncApprovals?: SyncApprovalsForDeals; + }; +} + +const defaultRunBulkApproveTx: RunBulkApproveTx = async ({ changes, userId }) => { + return db.transaction(async (tx) => { + const now = new Date(); + + for (const change of changes) { + await tx + .insert(dealMeasureApprovals) + .values({ + hubspotDealId: change.hubspotDealId, + measureName: change.measureName, + isApproved: change.approved, + approvedBy: userId, + approvedAt: now, + }) + .onConflictDoUpdate({ + target: [dealMeasureApprovals.hubspotDealId, dealMeasureApprovals.measureName], + set: { isApproved: change.approved, approvedBy: userId, approvedAt: now }, + }); + + await tx.insert(dealMeasureApprovalEvents).values({ + hubspotDealId: change.hubspotDealId, + measureName: change.measureName, + action: change.approved ? "approved" : "unapproved", + actedBy: userId, + actedAt: now, + }); + } + + const dealIds = [...new Set(changes.map((c) => c.hubspotDealId))]; + const approvalRows = await tx + .select({ + hubspotDealId: dealMeasureApprovals.hubspotDealId, + measureName: dealMeasureApprovals.measureName, + }) + .from(dealMeasureApprovals) + .where( + and( + inArray(dealMeasureApprovals.hubspotDealId, dealIds), + eq(dealMeasureApprovals.isApproved, true), + ), + ); + + const approvedMeasuresByDeal: Record = {}; + for (const row of approvalRows) { + (approvedMeasuresByDeal[row.hubspotDealId] ??= []).push(row.measureName); + } + return { approvedMeasuresByDeal }; + }); +}; + +const defaultSyncApprovals: SyncApprovalsForDeals = async ({ + approvedMeasuresByDeal, + actedByEmail, + actedAt, +}) => { + const dealIds = Object.keys(approvedMeasuresByDeal); + for (const dealId of dealIds) { + const measures = approvedMeasuresByDeal[dealId] ?? []; + + void syncMeasureApprovalsToHubSpot({ + hubspotDealId: dealId, + approvedMeasures: measures.map((m) => ({ measureName: m, approvedByEmail: actedByEmail })), + actedByEmail, + actedAt, + }); + + const result = await syncMeasuresFieldToHubSpot({ + hubspotDealId: dealId, + propName: APPROVED_MEASURES_PROP, + measureNames: measures, + }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + } + return { ok: true }; +}; + +export async function bulkApprove(input: BulkApproveInput): Promise { + if (input.changes.length === 0) { + return { ok: false, error: "changes must not be empty" }; + } + + const runTx = input.deps?.runBulkApproveTx ?? defaultRunBulkApproveTx; + const syncApprovals = input.deps?.syncApprovals ?? defaultSyncApprovals; + + let txResult: BulkApproveTxResult; + try { + txResult = await runTx({ changes: input.changes, userId: input.userId }); + } catch (err) { + const message = err instanceof Error ? err.message : "Bulk approve transaction failed"; + console.error("[bulkApprove] transaction failed", { error: err }); + return { ok: false, error: message }; + } + + const syncResult = await syncApprovals({ + approvedMeasuresByDeal: txResult.approvedMeasuresByDeal, + actedByEmail: input.actedByEmail ?? "unknown", + actedAt: new Date(), + }); + + if (!syncResult.ok) { + return { ok: true, hubspotSync: "failed", hubspotError: syncResult.error }; + } + + return { ok: true, hubspotSync: "ok" }; +} diff --git a/src/app/lib/bulkInstructDeals.test.ts b/src/app/lib/bulkInstructDeals.test.ts new file mode 100644 index 0000000..db33c2f --- /dev/null +++ b/src/app/lib/bulkInstructDeals.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from "vitest"; +import { bulkInstructDeals } from "./bulkInstructDeals"; +import type { RunBulkInstructTx, SyncInstructedForDeals } from "./bulkInstructDeals"; + +const noopSync: SyncInstructedForDeals = async () => ({ ok: true }); + +function makeSuccessTx(): RunBulkInstructTx { + return async ({ deals }) => ({ + instructedRowIds: deals.map((_, i) => BigInt(i + 1)), + approvedMeasuresByDeal: Object.fromEntries( + deals.map((d) => [d.dealId, d.measureNames]), + ), + }); +} + +describe("bulkInstructDeals", () => { + it("returns ok:false when deals array is empty", async () => { + const result = await bulkInstructDeals({ + deals: [], + userId: 1n, + deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync }, + }); + expect(result).toEqual({ ok: false, error: "deals must not be empty" }); + }); + + it("returns ok:false when a deal has empty measureNames", async () => { + const result = await bulkInstructDeals({ + deals: [{ dealId: "deal-1", measureNames: [] }], + userId: 1n, + deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync }, + }); + expect(result).toMatchObject({ ok: false }); + }); + + it("returns ok:false for unknown measure name", async () => { + const result = await bulkInstructDeals({ + deals: [{ dealId: "deal-1", measureNames: ["Not A Real Measure"] }], + userId: 1n, + deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync }, + }); + expect(result).toMatchObject({ ok: false, error: expect.stringContaining("Unknown measure") }); + }); + + it("returns ok:true when all deals succeed", async () => { + const result = await bulkInstructDeals({ + deals: [ + { dealId: "deal-1", measureNames: ["ASHP", "CWI"] }, + { dealId: "deal-2", measureNames: ["EWI"] }, + ], + userId: 1n, + deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync }, + }); + expect(result).toMatchObject({ ok: true, hubspotSync: "ok" }); + }); + + it("returns ok:false when tx throws (atomic rollback)", async () => { + const failingTx: RunBulkInstructTx = async () => { + throw new Error("FK violation"); + }; + const result = await bulkInstructDeals({ + deals: [{ dealId: "deal-1", measureNames: ["ASHP"] }], + userId: 1n, + deps: { runBulkInstructTx: failingTx, syncInstructed: noopSync }, + }); + expect(result).toMatchObject({ ok: false, error: "FK violation" }); + }); + + it("returns ok:true with hubspotSync:'failed' when sync fails", async () => { + const failSync: SyncInstructedForDeals = async () => ({ + ok: false, + error: "HubSpot rate limit", + }); + const result = await bulkInstructDeals({ + deals: [{ dealId: "deal-1", measureNames: ["ASHP"] }], + userId: 1n, + deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: failSync }, + }); + expect(result).toMatchObject({ ok: true, hubspotSync: "failed", hubspotError: "HubSpot rate limit" }); + }); + + it("passes all deals to the tx in one call", async () => { + const txSpy = vi.fn(makeSuccessTx()); + await bulkInstructDeals({ + deals: [ + { dealId: "deal-1", measureNames: ["ASHP"] }, + { dealId: "deal-2", measureNames: ["CWI"] }, + ], + userId: 1n, + deps: { runBulkInstructTx: txSpy, syncInstructed: noopSync }, + }); + expect(txSpy).toHaveBeenCalledOnce(); + expect(txSpy.mock.calls[0][0].deals).toHaveLength(2); + }); +}); diff --git a/src/app/lib/bulkInstructDeals.ts b/src/app/lib/bulkInstructDeals.ts new file mode 100644 index 0000000..27bf2ea --- /dev/null +++ b/src/app/lib/bulkInstructDeals.ts @@ -0,0 +1,179 @@ +import { db } from "@/app/db/db"; +import { and, eq } from "drizzle-orm"; +import { + dealMeasureApprovals, + dealMeasureApprovalEvents, +} from "@/app/db/schema/approvals"; +import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures"; +import { MEASURE_NAMES, type MeasureName } from "@/app/lib/measureDocumentRequirements"; +import { syncMeasuresFieldToHubSpot } from "@/app/lib/hubspot/dealSync"; +import { + INSTRUCTED_MEASURES_PROP, + PROPOSED_MEASURES_PROP, + APPROVED_MEASURES_PROP, +} from "@/app/lib/instructMeasure"; +import { parseMeasures } from "@/app/lib/parseMeasures"; +import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; + +export interface BulkInstructDeal { + dealId: string; + measureNames: string[]; +} + +export interface BulkInstructTxResult { + instructedRowIds: bigint[]; + approvedMeasuresByDeal: Record; +} + +export type RunBulkInstructTx = (params: { + deals: Array<{ dealId: string; measureNames: MeasureName[] }>; + userId: bigint; + notes: string | null; +}) => Promise; + +export type SyncInstructedForDeals = (params: { + approvedMeasuresByDeal: Record; +}) => Promise<{ ok: true } | { ok: false; error: string }>; + +export type BulkInstructDealsResult = + | { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string } + | { ok: false; error: string }; + +export interface BulkInstructDealsInput { + deals: BulkInstructDeal[]; + userId: bigint; + notes?: string; + deps?: { + runBulkInstructTx?: RunBulkInstructTx; + syncInstructed?: SyncInstructedForDeals; + }; +} + +function isMeasureName(value: string): value is MeasureName { + return (MEASURE_NAMES as ReadonlyArray).includes(value); +} + +const defaultRunBulkInstructTx: RunBulkInstructTx = async ({ deals, userId, notes }) => { + return db.transaction(async (tx) => { + const instructedRowIds: bigint[] = []; + const approvedMeasuresByDeal: Record = {}; + + for (const { dealId, measureNames } of deals) { + for (const measureName of measureNames) { + const inserted = await tx + .insert(userDefinedDealMeasures) + .values({ hubspotDealId: dealId, measureName, source: "instructed", createdByUserId: userId, notes }) + .returning({ id: userDefinedDealMeasures.id }); + + const rowId = inserted[0]?.id; + if (!rowId) throw new Error(`Failed to insert instructed measure row for deal ${dealId}`); + instructedRowIds.push(rowId); + + await tx + .insert(dealMeasureApprovals) + .values({ hubspotDealId: dealId, measureName, isApproved: true, approvedBy: userId }) + .onConflictDoUpdate({ + target: [dealMeasureApprovals.hubspotDealId, dealMeasureApprovals.measureName], + set: { isApproved: true, approvedBy: userId, approvedAt: new Date() }, + }); + + await tx.insert(dealMeasureApprovalEvents).values({ + hubspotDealId: dealId, + measureName, + action: "approved", + actedBy: userId, + }); + } + + const approvalRows = await tx + .select({ measureName: dealMeasureApprovals.measureName }) + .from(dealMeasureApprovals) + .where( + and( + eq(dealMeasureApprovals.hubspotDealId, dealId), + eq(dealMeasureApprovals.isApproved, true), + ), + ); + approvedMeasuresByDeal[dealId] = approvalRows.map((r) => r.measureName); + } + + return { instructedRowIds, approvedMeasuresByDeal }; + }); +}; + +const defaultSyncInstructed: SyncInstructedForDeals = async ({ approvedMeasuresByDeal }) => { + for (const [dealId, measures] of Object.entries(approvedMeasuresByDeal)) { + const instructedRows = await db + .select({ measureName: userDefinedDealMeasures.measureName }) + .from(userDefinedDealMeasures) + .where( + and( + eq(userDefinedDealMeasures.hubspotDealId, dealId), + eq(userDefinedDealMeasures.source, "instructed"), + ), + ); + const allInstructed = instructedRows.map((r) => r.measureName); + + const dealRow = await db + .select({ proposedMeasures: hubspotDealData.proposedMeasures }) + .from(hubspotDealData) + .where(eq(hubspotDealData.dealId, dealId)) + .limit(1); + const existing = parseMeasures(dealRow[0]?.proposedMeasures ?? null); + const mergedProposed = [...new Set([...existing, ...allInstructed])]; + + const r1 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: INSTRUCTED_MEASURES_PROP, measureNames: allInstructed }); + if (!r1.ok) return { ok: false, error: r1.error }; + + const r2 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: PROPOSED_MEASURES_PROP, measureNames: mergedProposed }); + if (!r2.ok) return { ok: false, error: r2.error }; + + const r3 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: APPROVED_MEASURES_PROP, measureNames: measures }); + if (!r3.ok) return { ok: false, error: r3.error }; + } + return { ok: true }; +}; + +export async function bulkInstructDeals( + input: BulkInstructDealsInput, +): Promise { + if (input.deals.length === 0) { + return { ok: false, error: "deals must not be empty" }; + } + + for (const { measureNames } of input.deals) { + if (measureNames.length === 0) { + return { ok: false, error: "each deal must have at least one measureName" }; + } + for (const name of measureNames) { + if (!isMeasureName(name.trim())) { + return { ok: false, error: `Unknown measure: ${name}` }; + } + } + } + + const validatedDeals = input.deals.map((d) => ({ + dealId: d.dealId, + measureNames: d.measureNames.map((m) => m.trim() as MeasureName), + })); + + const runTx = input.deps?.runBulkInstructTx ?? defaultRunBulkInstructTx; + const syncInstructed = input.deps?.syncInstructed ?? defaultSyncInstructed; + + let txResult: BulkInstructTxResult; + try { + txResult = await runTx({ deals: validatedDeals, userId: input.userId, notes: input.notes ?? null }); + } catch (err) { + const message = err instanceof Error ? err.message : "Bulk instruct transaction failed"; + console.error("[bulkInstructDeals] transaction failed", { error: err }); + return { ok: false, error: message }; + } + + const syncResult = await syncInstructed({ approvedMeasuresByDeal: txResult.approvedMeasuresByDeal }); + + if (!syncResult.ok) { + return { ok: true, hubspotSync: "failed", hubspotError: syncResult.error }; + } + + return { ok: true, hubspotSync: "ok" }; +} diff --git a/src/app/lib/createPibiRequests.test.ts b/src/app/lib/createPibiRequests.test.ts new file mode 100644 index 0000000..24e230b --- /dev/null +++ b/src/app/lib/createPibiRequests.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPibiRequests, PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests"; +import type { RunCreatePibiTx, SyncMeasuresField, StampPushedAt } from "./createPibiRequests"; + +function makeDeps(overrides?: { + txResult?: { insertedRowIds: bigint[]; allMeasureNames: string[] }; + txError?: Error; + syncResult?: { ok: true } | { ok: false; error: string }; + stampError?: Error; +}) { + const txResult = overrides?.txResult ?? { + insertedRowIds: [1n, 2n], + allMeasureNames: ["CWI", "Loft insulation"], + }; + + const runCreateTx: RunCreatePibiTx = vi.fn(async () => { + if (overrides?.txError) throw overrides.txError; + return txResult; + }); + + const syncMeasuresField: SyncMeasuresField = vi.fn(async () => { + return overrides?.syncResult ?? ({ ok: true } as const); + }); + + const stampPushedAt: StampPushedAt = vi.fn(async () => { + if (overrides?.stampError) throw overrides.stampError; + }); + + return { runCreateTx, syncMeasuresField, stampPushedAt }; +} + +describe("createPibiRequests — happy path", () => { + it("inserts rows, syncs all deal measures to HubSpot, stamps pushed_at", async () => { + const orderedAt = new Date("2026-05-06T10:00:00Z"); + const deps = makeDeps({ + txResult: { insertedRowIds: [10n, 11n], allMeasureNames: ["CWI", "Loft insulation"] }, + }); + + const result = await createPibiRequests({ + dealId: "deal-1", + portfolioId: 42n, + measureNames: ["CWI", "Loft insulation"], + orderedAt, + userId: 5n, + deps, + }); + + expect(result).toMatchObject({ + ok: true, + insertedRowIds: [10n, 11n], + hubspotSync: "ok", + }); + + expect(deps.runCreateTx).toHaveBeenCalledWith({ + dealId: "deal-1", + portfolioId: 42n, + measureNames: ["CWI", "Loft insulation"], + orderedAt, + userId: 5n, + }); + + expect(deps.syncMeasuresField).toHaveBeenCalledWith({ + hubspotDealId: "deal-1", + propName: PIBI_ORDERED_TEXT_PROP, + measureNames: ["CWI", "Loft insulation"], + }); + + expect(deps.stampPushedAt).toHaveBeenCalledWith([10n, 11n]); + }); + + it("uses now() when orderedAt is omitted", async () => { + const before = new Date(); + const deps = makeDeps(); + + await createPibiRequests({ + dealId: "deal-2", + portfolioId: 1n, + measureNames: ["ASHP"], + userId: 1n, + deps, + }); + + const callArg = (deps.runCreateTx as ReturnType).mock.calls[0][0] as { + orderedAt: Date; + }; + expect(callArg.orderedAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + }); +}); + +describe("createPibiRequests — validation", () => { + it("returns ok=false immediately when measureNames is empty", async () => { + const deps = makeDeps(); + + const result = await createPibiRequests({ + dealId: "deal-3", + portfolioId: 1n, + measureNames: [], + userId: 1n, + deps, + }); + + expect(result).toEqual({ ok: false, error: "measureNames must not be empty" }); + expect(deps.runCreateTx).not.toHaveBeenCalled(); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + }); +}); + +describe("createPibiRequests — DB failure", () => { + it("returns ok=false, skips HubSpot when tx throws", async () => { + const deps = makeDeps({ txError: new Error("insert failed") }); + + const result = await createPibiRequests({ + dealId: "deal-x", + portfolioId: 1n, + measureNames: ["EWI"], + userId: 1n, + deps, + }); + + expect(result).toEqual({ ok: false, error: "insert failed" }); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); +}); + +describe("createPibiRequests — HubSpot failure", () => { + it("returns ok=true with hubspotSync=failed, does NOT stamp pushed_at", async () => { + const deps = makeDeps({ + txResult: { insertedRowIds: [20n], allMeasureNames: ["Solar PV"] }, + syncResult: { ok: false, error: "hubspot 503" }, + }); + + const result = await createPibiRequests({ + dealId: "deal-h", + portfolioId: 1n, + measureNames: ["Solar PV"], + userId: 2n, + deps, + }); + + expect(result).toMatchObject({ + ok: true, + insertedRowIds: [20n], + hubspotSync: "failed", + hubspotError: "hubspot 503", + }); + + expect(deps.runCreateTx).toHaveBeenCalledTimes(1); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/lib/createPibiRequests.ts b/src/app/lib/createPibiRequests.ts new file mode 100644 index 0000000..b6a95d8 --- /dev/null +++ b/src/app/lib/createPibiRequests.ts @@ -0,0 +1,160 @@ +import { db } from "@/app/db/db"; +import { pibiRequests } from "@/app/db/schema/pibi_requests"; +import { eq } from "drizzle-orm"; +import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync"; + +export const PIBI_ORDERED_TEXT_PROP = "measures_for_pibi_ordered_text"; + +// --------------------------------------------------------------------------- +// Injectable dep types +// --------------------------------------------------------------------------- + +export type RunCreatePibiTx = (params: { + dealId: string; + portfolioId: bigint; + measureNames: string[]; + orderedAt: Date; + userId: bigint; +}) => Promise<{ insertedRowIds: bigint[]; allMeasureNames: string[] }>; + +export type SyncMeasuresField = typeof defaultSyncMeasuresField; + +export type StampPushedAt = (rowIds: bigint[]) => Promise; + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +export type CreatePibiRequestsResult = + | { + ok: true; + insertedRowIds: bigint[]; + hubspotSync: "ok" | "failed"; + hubspotError?: string; + } + | { ok: false; error: string }; + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +export interface CreatePibiRequestsInput { + dealId: string; + portfolioId: bigint; + measureNames: string[]; + /** Defaults to now() when omitted. */ + orderedAt?: Date; + userId: bigint; + deps?: { + runCreateTx?: RunCreatePibiTx; + syncMeasuresField?: SyncMeasuresField; + stampPushedAt?: StampPushedAt; + }; +} + +// --------------------------------------------------------------------------- +// Default DB-backed implementations +// --------------------------------------------------------------------------- + +const defaultRunCreateTx: RunCreatePibiTx = async ({ + dealId, + portfolioId, + measureNames, + orderedAt, + userId, +}) => { + return await db.transaction(async (tx) => { + const inserted = await tx + .insert(pibiRequests) + .values( + measureNames.map((measureName) => ({ + hubspotDealId: dealId, + portfolioId, + measureName, + orderedAt, + createdByUserId: userId, + })), + ) + .returning({ id: pibiRequests.id }); + + const allRows = await tx + .select({ measureName: pibiRequests.measureName }) + .from(pibiRequests) + .where(eq(pibiRequests.hubspotDealId, dealId)); + + return { + insertedRowIds: inserted.map((r) => r.id), + allMeasureNames: allRows.map((r) => r.measureName), + }; + }); +}; + +const defaultStampPushedAt: StampPushedAt = async (rowIds) => { + if (rowIds.length === 0) return; + for (const rowId of rowIds) { + await db + .update(pibiRequests) + .set({ pushedAt: new Date() }) + .where(eq(pibiRequests.id, rowId)); + } +}; + +// --------------------------------------------------------------------------- +// Service entry-point +// --------------------------------------------------------------------------- + +export async function createPibiRequests( + input: CreatePibiRequestsInput, +): Promise { + if (input.measureNames.length === 0) { + return { ok: false, error: "measureNames must not be empty" }; + } + + const runCreateTx = input.deps?.runCreateTx ?? defaultRunCreateTx; + const syncMeasuresField = input.deps?.syncMeasuresField ?? defaultSyncMeasuresField; + const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt; + const orderedAt = input.orderedAt ?? new Date(); + + let txResult: { insertedRowIds: bigint[]; allMeasureNames: string[] }; + try { + txResult = await runCreateTx({ + dealId: input.dealId, + portfolioId: input.portfolioId, + measureNames: input.measureNames, + orderedAt, + userId: input.userId, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create PIBI requests"; + return { ok: false, error: message }; + } + + const syncResult = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: PIBI_ORDERED_TEXT_PROP, + measureNames: txResult.allMeasureNames, + }); + + if (syncResult.ok) { + try { + await stampPushedAt(txResult.insertedRowIds); + } catch (err) { + console.error("[createPibiRequests] failed to stamp pushed_at", { + rowIds: txResult.insertedRowIds.map(String), + error: err, + }); + } + return { + ok: true, + insertedRowIds: txResult.insertedRowIds, + hubspotSync: "ok", + }; + } + + return { + ok: true, + insertedRowIds: txResult.insertedRowIds, + hubspotSync: "failed", + hubspotError: syncResult.error, + }; +} diff --git a/src/app/lib/dealPropertyUpdate.test.ts b/src/app/lib/dealPropertyUpdate.test.ts index 2f092b5..4c2b34c 100644 --- a/src/app/lib/dealPropertyUpdate.test.ts +++ b/src/app/lib/dealPropertyUpdate.test.ts @@ -75,10 +75,10 @@ describe("DEAL_PROPERTY_FIELDS registry", () => { "property_halted_reason", ); expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe( - "domna_survey_type", + "osmosis_survey_required", ); expect(DEAL_PROPERTY_FIELDS.domna_survey_date.hubspotProperty).toBe( - "domna_survey_date", + "osmosis_survey_date", ); }); @@ -369,8 +369,8 @@ describe("applyDealPropertyUpdate", () => { expect((dbValues.domnaSurveyDate as Date).toISOString()).toBe(surveyIso); const props = pushHubspot.mock.calls[0][0].properties; - expect(props.domna_survey_type).toBe(surveyType); - expect(props.domna_survey_date).toBe( + expect(props.osmosis_survey_required).toBe(surveyType); + expect(props.osmosis_survey_date).toBe( String(new Date(surveyIso).getTime()), ); }); @@ -407,8 +407,8 @@ describe("applyDealPropertyUpdate", () => { expect(typeOnlyDb.domnaSurveyType).toBe("Standard"); expect("domnaSurveyDate" in typeOnlyDb).toBe(false); const typeOnlyProps = pushHubspotType.mock.calls[0][0].properties; - expect(typeOnlyProps.domna_survey_type).toBe("Standard"); - expect("domna_survey_date" in typeOnlyProps).toBe(false); + expect(typeOnlyProps.osmosis_survey_required).toBe("Standard"); + expect("osmosis_survey_date" in typeOnlyProps).toBe(false); // Setting only the date — type column is untouched. const updateDbDate = vi.fn().mockResolvedValue(undefined); @@ -427,10 +427,10 @@ describe("applyDealPropertyUpdate", () => { expect(dateOnlyDb.domnaSurveyDate).toBeInstanceOf(Date); expect("domnaSurveyType" in dateOnlyDb).toBe(false); const dateOnlyProps = pushHubspotDate.mock.calls[0][0].properties; - expect(dateOnlyProps.domna_survey_date).toBe( + expect(dateOnlyProps.osmosis_survey_date).toBe( String(new Date(surveyIso).getTime()), ); - expect("domna_survey_type" in dateOnlyProps).toBe(false); + expect("osmosis_survey_required" in dateOnlyProps).toBe(false); }); it("clears both domna fields to null when explicitly cleared", async () => { @@ -453,8 +453,8 @@ describe("applyDealPropertyUpdate", () => { expect(dbValues.domnaSurveyType).toBeNull(); expect(dbValues.domnaSurveyDate).toBeNull(); const props = pushHubspot.mock.calls[0][0].properties; - expect(props.domna_survey_type).toBe(""); - expect(props.domna_survey_date).toBe(""); + expect(props.osmosis_survey_required).toBe(""); + expect(props.osmosis_survey_date).toBe(""); }); it("surfaces HubSpot push failures back to the caller", async () => { diff --git a/src/app/lib/dealPropertyUpdate.ts b/src/app/lib/dealPropertyUpdate.ts index 5aaf122..2336006 100644 --- a/src/app/lib/dealPropertyUpdate.ts +++ b/src/app/lib/dealPropertyUpdate.ts @@ -149,14 +149,14 @@ export const DEAL_PROPERTY_FIELDS = { domna_survey_type: { schema: stringOrNullSchema, allowedRoles: APPROVER_ROLES, - hubspotProperty: "domna_survey_type", + hubspotProperty: "osmosis_survey_required", dbColumn: hubspotDealData.domnaSurveyType, toHubspot: stringToHubspot, } satisfies DealPropertyFieldDef, domna_survey_date: { schema: isoDateSchema, allowedRoles: APPROVER_ROLES, - hubspotProperty: "domna_survey_date", + hubspotProperty: "osmosis_survey_date", dbColumn: hubspotDealData.domnaSurveyDate, toHubspot: dateToHubspot, } satisfies DealPropertyFieldDef, diff --git a/src/app/lib/deletePibiRequest.test.ts b/src/app/lib/deletePibiRequest.test.ts new file mode 100644 index 0000000..924ffbf --- /dev/null +++ b/src/app/lib/deletePibiRequest.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { deletePibiRequest, PIBI_ORDERED_TEXT_PROP } from "./deletePibiRequest"; +import type { RunDeletePibiTx, SyncMeasuresField } from "./deletePibiRequest"; + +function makeDeps(overrides?: { + txResult?: { remainingMeasureNames: string[] }; + txError?: Error; + syncResult?: { ok: true } | { ok: false; error: string }; +}) { + const txResult = overrides?.txResult ?? { remainingMeasureNames: ["ASHP"] }; + + const runDeleteTx: RunDeletePibiTx = vi.fn(async () => { + if (overrides?.txError) throw overrides.txError; + return txResult; + }); + + const syncMeasuresField: SyncMeasuresField = vi.fn(async () => { + return overrides?.syncResult ?? ({ ok: true } as const); + }); + + return { runDeleteTx, syncMeasuresField }; +} + +describe("deletePibiRequest — happy path", () => { + it("deletes row, re-syncs remaining deal measures to HubSpot", async () => { + const deps = makeDeps({ + txResult: { remainingMeasureNames: ["ASHP"] }, + }); + + const result = await deletePibiRequest({ + id: 7n, + dealId: "deal-1", + deps, + }); + + expect(result).toEqual({ ok: true, hubspotSync: "ok" }); + + expect(deps.runDeleteTx).toHaveBeenCalledWith({ id: 7n, dealId: "deal-1" }); + + expect(deps.syncMeasuresField).toHaveBeenCalledWith({ + hubspotDealId: "deal-1", + propName: PIBI_ORDERED_TEXT_PROP, + measureNames: ["ASHP"], + }); + }); + + it("syncs empty list when last PIBI is deleted", async () => { + const deps = makeDeps({ txResult: { remainingMeasureNames: [] } }); + + const result = await deletePibiRequest({ id: 1n, dealId: "deal-2", deps }); + + expect(result).toEqual({ ok: true, hubspotSync: "ok" }); + expect(deps.syncMeasuresField).toHaveBeenCalledWith({ + hubspotDealId: "deal-2", + propName: PIBI_ORDERED_TEXT_PROP, + measureNames: [], + }); + }); +}); + +describe("deletePibiRequest — DB failure", () => { + it("returns ok=false, skips HubSpot when tx throws", async () => { + const deps = makeDeps({ txError: new Error("row not found") }); + + const result = await deletePibiRequest({ id: 99n, dealId: "deal-x", deps }); + + expect(result).toEqual({ ok: false, error: "row not found" }); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + }); +}); + +describe("deletePibiRequest — HubSpot failure", () => { + it("returns ok=true with hubspotSync=failed", async () => { + const deps = makeDeps({ + syncResult: { ok: false, error: "hubspot down" }, + }); + + const result = await deletePibiRequest({ id: 5n, dealId: "deal-h", deps }); + + expect(result).toMatchObject({ + ok: true, + hubspotSync: "failed", + hubspotError: "hubspot down", + }); + }); +}); diff --git a/src/app/lib/deletePibiRequest.ts b/src/app/lib/deletePibiRequest.ts new file mode 100644 index 0000000..25823fa --- /dev/null +++ b/src/app/lib/deletePibiRequest.ts @@ -0,0 +1,93 @@ +import { db } from "@/app/db/db"; +import { pibiRequests } from "@/app/db/schema/pibi_requests"; +import { eq, and } from "drizzle-orm"; +import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync"; +export { PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests"; +import { PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests"; + +// --------------------------------------------------------------------------- +// Injectable dep types +// --------------------------------------------------------------------------- + +export type RunDeletePibiTx = (params: { + id: bigint; + dealId: string; +}) => Promise<{ remainingMeasureNames: string[] }>; + +export type SyncMeasuresField = typeof defaultSyncMeasuresField; + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +export type DeletePibiRequestResult = + | { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string } + | { ok: false; error: string }; + +export interface DeletePibiRequestInput { + id: bigint; + dealId: string; + deps?: { + runDeleteTx?: RunDeletePibiTx; + syncMeasuresField?: SyncMeasuresField; + }; +} + +// --------------------------------------------------------------------------- +// Default DB-backed implementation +// --------------------------------------------------------------------------- + +const defaultRunDeleteTx: RunDeletePibiTx = async ({ id, dealId }) => { + return await db.transaction(async (tx) => { + const deleted = await tx + .delete(pibiRequests) + .where(and(eq(pibiRequests.id, id), eq(pibiRequests.hubspotDealId, dealId))) + .returning({ id: pibiRequests.id }); + + if (deleted.length === 0) { + throw new Error("PIBI request not found"); + } + + const remaining = await tx + .select({ measureName: pibiRequests.measureName }) + .from(pibiRequests) + .where(eq(pibiRequests.hubspotDealId, dealId)); + + return { remainingMeasureNames: remaining.map((r) => r.measureName) }; + }); +}; + +// --------------------------------------------------------------------------- +// Service entry-point +// --------------------------------------------------------------------------- + +export async function deletePibiRequest( + input: DeletePibiRequestInput, +): Promise { + const runDeleteTx = input.deps?.runDeleteTx ?? defaultRunDeleteTx; + const syncMeasuresField = input.deps?.syncMeasuresField ?? defaultSyncMeasuresField; + + let txResult: { remainingMeasureNames: string[] }; + try { + txResult = await runDeleteTx({ id: input.id, dealId: input.dealId }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to delete PIBI request"; + return { ok: false, error: message }; + } + + const syncResult = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: PIBI_ORDERED_TEXT_PROP, + measureNames: txResult.remainingMeasureNames, + }); + + if (syncResult.ok) { + return { ok: true, hubspotSync: "ok" }; + } + + return { + ok: true, + hubspotSync: "failed", + hubspotError: syncResult.error, + }; +} diff --git a/src/app/lib/hubspot/dealSync.test.ts b/src/app/lib/hubspot/dealSync.test.ts index 2ca92a0..47acf69 100644 --- a/src/app/lib/hubspot/dealSync.test.ts +++ b/src/app/lib/hubspot/dealSync.test.ts @@ -10,7 +10,7 @@ vi.mock("./client", () => ({ }), })); -import { syncMeasuresFieldToHubSpot } from "./dealSync"; +import { syncMeasuresFieldToHubSpot, syncSurveyRequestToHubSpot } from "./dealSync"; describe("syncMeasuresFieldToHubSpot", () => { beforeEach(() => { @@ -122,3 +122,36 @@ describe("syncMeasuresFieldToHubSpot", () => { }); }); }); + +describe("syncSurveyRequestToHubSpot", () => { + beforeEach(() => { + updateMock.mockReset(); + }); + + it("writes survey type to osmosis_survey_required and date to osmosis_survey_date", async () => { + updateMock.mockResolvedValueOnce(undefined); + const requestedAt = new Date("2026-05-06T10:00:00.000Z"); + const result = await syncSurveyRequestToHubSpot({ + hubspotDealId: "deal-99", + surveyType: "technical_building_survey", + requestedAt, + }); + expect(result).toEqual({ ok: true }); + expect(updateMock).toHaveBeenCalledWith("deal-99", { + properties: { + osmosis_survey_required: "technical_building_survey", + osmosis_survey_date: "2026-05-06", + }, + }); + }); + + it("returns ok: false with error message on HubSpot failure", async () => { + updateMock.mockRejectedValueOnce(new Error("HubSpot 400")); + const result = await syncSurveyRequestToHubSpot({ + hubspotDealId: "deal-99", + surveyType: "technical_building_survey", + requestedAt: new Date(), + }); + expect(result).toEqual({ ok: false, error: "HubSpot sync failed" }); + }); +}); diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts index 8b08c89..6f111ba 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -187,14 +187,16 @@ export async function syncMeasuresFieldToHubSpot(params: { export async function syncSurveyRequestToHubSpot(params: { hubspotDealId: string; - notes: string; - requestedByEmail: string; + surveyType: string; + requestedAt: Date; }): Promise<{ ok: boolean; error?: string }> { try { const client = getHubSpotClient(); - const log = `Survey requested by: ${params.requestedByEmail}\nNotes: ${params.notes}`; await client.crm.deals.basicApi.update(params.hubspotDealId, { - properties: { survey_request_log: log }, + properties: { + osmosis_survey_required: params.surveyType, + osmosis_survey_date: params.requestedAt.toISOString().slice(0, 10), + }, }); return { ok: true }; } catch (err) { diff --git a/src/app/lib/instructMeasure.test.ts b/src/app/lib/instructMeasure.test.ts index 7f8ae71..a9bf993 100644 --- a/src/app/lib/instructMeasure.test.ts +++ b/src/app/lib/instructMeasure.test.ts @@ -11,10 +11,13 @@ import { PROPOSED_MEASURES_PROP, APPROVED_MEASURES_PROP, instructMeasure, + instructMeasures, } from "./instructMeasure"; import type { InstructTxOutcome, + InstructMeasuresTxOutcome, RunInstructTx, + RunInstructMeasuresTx, ReadInstructedMeasureNames, StampPushedAt, SyncMeasuresField, @@ -307,3 +310,180 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => { expect(deps.stampPushedAt).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// instructMeasures (plural) — batch variant +// --------------------------------------------------------------------------- + +function makeBatchDeps(overrides?: { + txOutcome?: Partial; + txError?: Error; + instructedAfter?: string[]; + syncResults?: Array<{ ok: true } | { ok: false; error: string }>; + stampError?: Error; +}) { + const txOutcome: InstructMeasuresTxOutcome = { + instructedRowIds: [1n, 2n], + existingProposedMeasures: [], + allApprovedMeasureNames: [], + ...overrides?.txOutcome, + }; + const runInstructMeasuresTx: RunInstructMeasuresTx = vi.fn(async () => { + if (overrides?.txError) throw overrides.txError; + return txOutcome; + }); + const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn( + async () => overrides?.instructedAfter ?? ["ASHP", "Solar PV"], + ); + const syncQueue: Array<{ ok: true } | { ok: false; error: string }> = + overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }]; + const syncMeasuresField: SyncMeasuresField = vi.fn(async () => { + return syncQueue.shift() ?? ({ ok: true } as const); + }); + const stampPushedAt: StampPushedAt = vi.fn(async () => { + if (overrides?.stampError) throw overrides.stampError; + }); + return { + runInstructMeasuresTx, + readInstructedMeasureNames, + syncMeasuresField, + stampPushedAt, + }; +} + +describe("instructMeasures — input validation", () => { + it("rejects when measureNames is empty", async () => { + const deps = makeBatchDeps(); + const result = await instructMeasures({ + dealId: "deal-1", + measureNames: [], + userId: 1n, + deps, + }); + expect(result).toEqual({ ok: false, error: "measureNames must not be empty" }); + expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled(); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + }); + + it("rejects when any measureName is unknown", async () => { + const deps = makeBatchDeps(); + const result = await instructMeasures({ + dealId: "deal-1", + measureNames: ["ASHP", "Not a real measure"], + userId: 1n, + deps, + }); + expect(result).toEqual({ ok: false, error: "Unknown measure: Not a real measure" }); + expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled(); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + }); +}); + +describe("instructMeasures — happy path", () => { + it("commits single tx, pushes instructed + proposed + approved, stamps all rowIds", async () => { + const deps = makeBatchDeps({ + instructedAfter: ["ASHP", "Solar PV"], + txOutcome: { + instructedRowIds: [10n, 11n], + existingProposedMeasures: [], + allApprovedMeasureNames: ["ASHP", "Solar PV"], + }, + }); + const result = await instructMeasures({ + dealId: "deal-42", + measureNames: ["ASHP", "Solar PV"], + userId: 7n, + deps, + }); + expect(result).toMatchObject({ ok: true, instructedRowIds: [10n, 11n], hubspotSync: "ok" }); + expect(deps.runInstructMeasuresTx).toHaveBeenCalledOnce(); + expect(deps.runInstructMeasuresTx).toHaveBeenCalledWith({ + dealId: "deal-42", + measureNames: ["ASHP", "Solar PV"], + userId: 7n, + notes: null, + }); + expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, { + hubspotDealId: "deal-42", + propName: INSTRUCTED_MEASURES_PROP, + measureNames: ["ASHP", "Solar PV"], + }); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, { + hubspotDealId: "deal-42", + propName: PROPOSED_MEASURES_PROP, + measureNames: ["ASHP", "Solar PV"], + }); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, { + hubspotDealId: "deal-42", + propName: APPROVED_MEASURES_PROP, + measureNames: ["ASHP", "Solar PV"], + }); + expect(deps.stampPushedAt).toHaveBeenCalledTimes(2); + expect(deps.stampPushedAt).toHaveBeenCalledWith(10n); + expect(deps.stampPushedAt).toHaveBeenCalledWith(11n); + }); + + it("merges all new measures into existing proposed (deduped)", async () => { + const deps = makeBatchDeps({ + instructedAfter: ["ASHP", "EWI", "Solar PV"], + txOutcome: { + instructedRowIds: [20n, 21n], + existingProposedMeasures: ["ASHP", "Loft insulation"], + allApprovedMeasureNames: ["ASHP", "EWI", "Solar PV"], + }, + }); + await instructMeasures({ + dealId: "deal-merge", + measureNames: ["EWI", "Solar PV"], + userId: 3n, + deps, + }); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, { + hubspotDealId: "deal-merge", + propName: PROPOSED_MEASURES_PROP, + measureNames: ["ASHP", "Loft insulation", "EWI", "Solar PV"], + }); + }); +}); + +describe("instructMeasures — DB transaction failure", () => { + it("returns error and skips HubSpot when tx throws", async () => { + const deps = makeBatchDeps({ txError: new Error("batch insert failed") }); + const result = await instructMeasures({ + dealId: "deal-x", + measureNames: ["ASHP", "EWI"], + userId: 1n, + deps, + }); + expect(result).toEqual({ ok: false, error: "batch insert failed" }); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); +}); + +describe("instructMeasures — HubSpot push failure leaves DB committed", () => { + it("returns ok=true with hubspotSync=failed when sync fails, does NOT stamp", async () => { + const deps = makeBatchDeps({ + instructedAfter: ["ASHP", "EWI"], + txOutcome: { + instructedRowIds: [30n, 31n], + existingProposedMeasures: [], + allApprovedMeasureNames: ["ASHP", "EWI"], + }, + syncResults: [{ ok: false, error: "hubspot 500" }, { ok: true }, { ok: true }], + }); + const result = await instructMeasures({ + dealId: "deal-h", + measureNames: ["ASHP", "EWI"], + userId: 1n, + deps, + }); + expect(result).toMatchObject({ + ok: true, + hubspotSync: "failed", + hubspotError: "hubspot 500", + }); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/lib/instructMeasure.ts b/src/app/lib/instructMeasure.ts index a7421ca..3e66e5e 100644 --- a/src/app/lib/instructMeasure.ts +++ b/src/app/lib/instructMeasure.ts @@ -196,6 +196,224 @@ const defaultStampPushedAt: StampPushedAt = async (rowId) => { .where(eq(userDefinedDealMeasures.id, rowId)); }; +// --------------------------------------------------------------------------- +// Batch (plural) types +// --------------------------------------------------------------------------- + +export interface InstructMeasuresTxOutcome { + instructedRowIds: bigint[]; + existingProposedMeasures: string[]; + allApprovedMeasureNames: string[]; +} + +export type RunInstructMeasuresTx = (params: { + dealId: string; + measureNames: MeasureName[]; + userId: bigint; + notes: string | null; +}) => Promise; + +export type InstructMeasuresResult = + | { + ok: true; + instructedRowIds: bigint[]; + hubspotSync: "ok" | "failed"; + hubspotError?: string; + } + | { ok: false; error: string }; + +export interface InstructMeasuresInput { + dealId: string; + measureNames: string[]; + userId: bigint; + notes?: string; + deps?: { + runInstructMeasuresTx?: RunInstructMeasuresTx; + readInstructedMeasureNames?: ReadInstructedMeasureNames; + syncMeasuresField?: SyncMeasuresField; + stampPushedAt?: StampPushedAt; + }; +} + +const defaultRunInstructMeasuresTx: RunInstructMeasuresTx = async ({ + dealId, + measureNames, + userId, + notes, +}) => { + return await db.transaction(async (tx) => { + const instructedRowIds: bigint[] = []; + + for (const measureName of measureNames) { + const inserted = await tx + .insert(userDefinedDealMeasures) + .values({ + hubspotDealId: dealId, + measureName, + source: "instructed", + createdByUserId: userId, + notes, + }) + .returning({ id: userDefinedDealMeasures.id }); + const rowId = inserted[0]?.id; + if (rowId === undefined || rowId === null) { + throw new Error("Failed to insert user_defined_deal_measures row"); + } + instructedRowIds.push(rowId); + + await tx + .insert(dealMeasureApprovals) + .values({ + hubspotDealId: dealId, + measureName, + isApproved: true, + approvedBy: userId, + }) + .onConflictDoUpdate({ + target: [ + dealMeasureApprovals.hubspotDealId, + dealMeasureApprovals.measureName, + ], + set: { + isApproved: true, + approvedBy: userId, + approvedAt: new Date(), + }, + }); + + await tx.insert(dealMeasureApprovalEvents).values({ + hubspotDealId: dealId, + measureName, + action: "approved", + actedBy: userId, + }); + } + + const dealRows = await tx + .select({ proposedMeasures: hubspotDealData.proposedMeasures }) + .from(hubspotDealData) + .where(eq(hubspotDealData.dealId, dealId)) + .limit(1); + const existingProposedMeasures = parseMeasures(dealRows[0]?.proposedMeasures ?? null); + + const approvedRows = await tx + .select({ measureName: dealMeasureApprovals.measureName }) + .from(dealMeasureApprovals) + .where( + and( + eq(dealMeasureApprovals.hubspotDealId, dealId), + eq(dealMeasureApprovals.isApproved, true), + ), + ); + const allApprovedMeasureNames = approvedRows.map((r) => r.measureName); + + return { instructedRowIds, existingProposedMeasures, allApprovedMeasureNames }; + }); +}; + +export async function instructMeasures( + input: InstructMeasuresInput, +): Promise { + if (input.measureNames.length === 0) { + return { ok: false, error: "measureNames must not be empty" }; + } + + const validatedNames: MeasureName[] = []; + for (const name of input.measureNames) { + const trimmed = name.trim(); + if (!isMeasureName(trimmed)) { + return { ok: false, error: `Unknown measure: ${trimmed}` }; + } + validatedNames.push(trimmed); + } + + const runInstructMeasuresTx = + input.deps?.runInstructMeasuresTx ?? defaultRunInstructMeasuresTx; + const readInstructed = + input.deps?.readInstructedMeasureNames ?? defaultReadInstructedMeasureNames; + const syncMeasuresField = + input.deps?.syncMeasuresField ?? defaultSyncMeasuresField; + const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt; + + let txResult: InstructMeasuresTxOutcome; + try { + txResult = await runInstructMeasuresTx({ + dealId: input.dealId, + measureNames: validatedNames, + userId: input.userId, + notes: input.notes ?? null, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to instruct measures"; + console.error("[instructMeasures] transaction failed", { + dealId: input.dealId, + measureNames: validatedNames, + error: err, + }); + return { ok: false, error: message }; + } + + const allInstructed = await readInstructed(input.dealId); + + const mergedProposed = Array.from( + new Set([...txResult.existingProposedMeasures, ...validatedNames]), + ); + + const instructedSync = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: INSTRUCTED_MEASURES_PROP, + measureNames: allInstructed, + }); + + const proposedSync = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: PROPOSED_MEASURES_PROP, + measureNames: mergedProposed, + }); + + const approvedSync = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: APPROVED_MEASURES_PROP, + measureNames: txResult.allApprovedMeasureNames, + }); + + const overallOk = instructedSync.ok && proposedSync.ok && approvedSync.ok; + + if (overallOk) { + for (const rowId of txResult.instructedRowIds) { + try { + await stampPushedAt(rowId); + } catch (err) { + console.error("[instructMeasures] failed to stamp pushed_at", { + rowId: String(rowId), + error: err, + }); + } + } + return { + ok: true, + instructedRowIds: txResult.instructedRowIds, + hubspotSync: "ok", + }; + } + + const hubspotError = !instructedSync.ok + ? instructedSync.error + : !proposedSync.ok + ? proposedSync.error + : !approvedSync.ok + ? approvedSync.error + : "HubSpot sync failed"; + + return { + ok: true, + instructedRowIds: txResult.instructedRowIds, + hubspotSync: "failed", + hubspotError, + }; +} + export async function instructMeasure( input: InstructMeasureInput, ): Promise { diff --git a/src/app/lib/pibiSectionHelpers.test.ts b/src/app/lib/pibiSectionHelpers.test.ts new file mode 100644 index 0000000..20e6d0b --- /dev/null +++ b/src/app/lib/pibiSectionHelpers.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { + groupByBatch, + formatDate, + toDateInputValue, + dateInputToIso, +} from "./pibiSectionHelpers"; + +// ── groupByBatch ─────────────────────────────────────────────────────────────── + +describe("groupByBatch", () => { + it("returns empty array for no rows", () => { + expect(groupByBatch([])).toEqual([]); + }); + + it("groups rows with the same orderedAt into one batch", () => { + const orderedAt = "2026-05-01T00:00:00.000Z"; + const rows = [ + { id: "1", measureName: "CWI", orderedAt, completedAt: null }, + { id: "2", measureName: "ASHP", orderedAt, completedAt: null }, + ]; + const batches = groupByBatch(rows); + expect(batches).toHaveLength(1); + expect(batches[0].orderedAt).toBe(orderedAt); + expect(batches[0].rows).toHaveLength(2); + }); + + it("creates separate batches for different orderedAt values", () => { + const rows = [ + { id: "1", measureName: "CWI", orderedAt: "2026-05-01T00:00:00.000Z", completedAt: null }, + { id: "2", measureName: "ASHP", orderedAt: "2026-05-15T00:00:00.000Z", completedAt: null }, + ]; + const batches = groupByBatch(rows); + expect(batches).toHaveLength(2); + }); + + it("preserves insertion order of first-seen orderedAt keys", () => { + const first = "2026-04-01T00:00:00.000Z"; + const second = "2026-05-01T00:00:00.000Z"; + const rows = [ + { id: "1", measureName: "CWI", orderedAt: first, completedAt: null }, + { id: "2", measureName: "ASHP", orderedAt: second, completedAt: null }, + { id: "3", measureName: "EWI", orderedAt: first, completedAt: null }, + ]; + const batches = groupByBatch(rows); + expect(batches[0].orderedAt).toBe(first); + expect(batches[0].rows).toHaveLength(2); + expect(batches[1].orderedAt).toBe(second); + }); +}); + +// ── formatDate ───────────────────────────────────────────────────────────────── + +describe("formatDate", () => { + it("returns em-dash for null", () => { + expect(formatDate(null)).toBe("—"); + }); + + it("returns em-dash for empty string", () => { + expect(formatDate("")).toBe("—"); + }); + + it("formats a valid ISO date as dd Mon yyyy in en-GB locale", () => { + expect(formatDate("2026-05-06T00:00:00.000Z")).toBe("06 May 2026"); + }); + + it("returns em-dash for an unparseable string", () => { + expect(formatDate("not-a-date")).toBe("—"); + }); +}); + +// ── toDateInputValue ─────────────────────────────────────────────────────────── + +describe("toDateInputValue", () => { + it("returns empty string for null", () => { + expect(toDateInputValue(null)).toBe(""); + }); + + it("converts ISO to YYYY-MM-DD using UTC date parts", () => { + expect(toDateInputValue("2026-05-06T00:00:00.000Z")).toBe("2026-05-06"); + }); + + it("zero-pads month and day", () => { + expect(toDateInputValue("2026-01-09T00:00:00.000Z")).toBe("2026-01-09"); + }); + + it("returns empty string for unparseable input", () => { + expect(toDateInputValue("not-a-date")).toBe(""); + }); +}); + +// ── dateInputToIso ───────────────────────────────────────────────────────────── + +describe("dateInputToIso", () => { + it("returns null for empty string", () => { + expect(dateInputToIso("")).toBeNull(); + }); + + it("converts YYYY-MM-DD to midnight UTC ISO string", () => { + expect(dateInputToIso("2026-05-06")).toBe("2026-05-06T00:00:00.000Z"); + }); + + it("round-trips with toDateInputValue", () => { + const iso = "2026-03-15T00:00:00.000Z"; + expect(dateInputToIso(toDateInputValue(iso))).toBe(iso); + }); +}); diff --git a/src/app/lib/pibiSectionHelpers.ts b/src/app/lib/pibiSectionHelpers.ts new file mode 100644 index 0000000..e4dbfc7 --- /dev/null +++ b/src/app/lib/pibiSectionHelpers.ts @@ -0,0 +1,53 @@ +export type PibiRow = { + id: string; + measureName: string; + orderedAt: string; + completedAt: string | null; +}; + +export type PibiBatch = { + orderedAt: string; + rows: PibiRow[]; +}; + +export function groupByBatch(rows: PibiRow[]): PibiBatch[] { + const map = new Map(); + for (const row of rows) { + const key = row.orderedAt; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(row); + } + return Array.from(map.entries()).map(([orderedAt, rows]) => ({ orderedAt, rows })); +} + +export function formatDate(iso: string | null): string { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return "—"; + return d.toLocaleDateString("en-GB", { + day: "2-digit", month: "short", year: "numeric", + }); + } catch { + return "—"; + } +} + +export function toDateInputValue(iso: string | null): string { + if (!iso) return ""; + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; + } catch { + return ""; + } +} + +export function dateInputToIso(value: string): string | null { + if (!value) return null; + return new Date(`${value}T00:00:00.000Z`).toISOString(); +} diff --git a/src/app/lib/updatePibiRequest.test.ts b/src/app/lib/updatePibiRequest.test.ts new file mode 100644 index 0000000..a823e9d --- /dev/null +++ b/src/app/lib/updatePibiRequest.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import { updatePibiRequest, PIBI_ORDERED_TEXT_PROP } from "./updatePibiRequest"; +import type { RunUpdatePibiTx, SyncMeasuresField } from "./updatePibiRequest"; + +function makeDeps(overrides?: { + txResult?: { allMeasureNames: string[] }; + txError?: Error; + syncResult?: { ok: true } | { ok: false; error: string }; +}) { + const txResult = overrides?.txResult ?? { allMeasureNames: ["CWI", "ASHP"] }; + + const runUpdateTx: RunUpdatePibiTx = vi.fn(async () => { + if (overrides?.txError) throw overrides.txError; + return txResult; + }); + + const syncMeasuresField: SyncMeasuresField = vi.fn(async () => { + return overrides?.syncResult ?? ({ ok: true } as const); + }); + + return { runUpdateTx, syncMeasuresField }; +} + +describe("updatePibiRequest — happy path", () => { + it("updates row, re-syncs all deal measures to HubSpot", async () => { + const completedAt = new Date("2026-05-10T12:00:00Z"); + const deps = makeDeps({ + txResult: { allMeasureNames: ["CWI", "ASHP"] }, + }); + + const result = await updatePibiRequest({ + id: 7n, + dealId: "deal-1", + updates: { completedAt }, + deps, + }); + + expect(result).toEqual({ ok: true, hubspotSync: "ok" }); + + expect(deps.runUpdateTx).toHaveBeenCalledWith({ + id: 7n, + dealId: "deal-1", + updates: { completedAt }, + }); + + expect(deps.syncMeasuresField).toHaveBeenCalledWith({ + hubspotDealId: "deal-1", + propName: PIBI_ORDERED_TEXT_PROP, + measureNames: ["CWI", "ASHP"], + }); + }); + + it("can update measureName, orderedAt, and completedAt", async () => { + const orderedAt = new Date("2026-05-01T09:00:00Z"); + const deps = makeDeps(); + + await updatePibiRequest({ + id: 3n, + dealId: "deal-2", + updates: { measureName: "EWI", orderedAt }, + deps, + }); + + expect(deps.runUpdateTx).toHaveBeenCalledWith({ + id: 3n, + dealId: "deal-2", + updates: { measureName: "EWI", orderedAt }, + }); + }); +}); + +describe("updatePibiRequest — DB failure", () => { + it("returns ok=false, skips HubSpot when tx throws", async () => { + const deps = makeDeps({ txError: new Error("row not found") }); + + const result = await updatePibiRequest({ + id: 99n, + dealId: "deal-x", + updates: { completedAt: new Date() }, + deps, + }); + + expect(result).toEqual({ ok: false, error: "row not found" }); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + }); +}); + +describe("updatePibiRequest — HubSpot failure", () => { + it("returns ok=true with hubspotSync=failed", async () => { + const deps = makeDeps({ + syncResult: { ok: false, error: "hubspot timeout" }, + }); + + const result = await updatePibiRequest({ + id: 5n, + dealId: "deal-h", + updates: { completedAt: new Date() }, + deps, + }); + + expect(result).toMatchObject({ + ok: true, + hubspotSync: "failed", + hubspotError: "hubspot timeout", + }); + }); +}); diff --git a/src/app/lib/updatePibiRequest.ts b/src/app/lib/updatePibiRequest.ts new file mode 100644 index 0000000..e329a8c --- /dev/null +++ b/src/app/lib/updatePibiRequest.ts @@ -0,0 +1,111 @@ +import { db } from "@/app/db/db"; +import { pibiRequests } from "@/app/db/schema/pibi_requests"; +import { eq, and } from "drizzle-orm"; +import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync"; + +export { PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests"; +import { PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests"; + +// --------------------------------------------------------------------------- +// Injectable dep types +// --------------------------------------------------------------------------- + +export type RunUpdatePibiTx = (params: { + id: bigint; + dealId: string; + updates: PibiRequestUpdates; +}) => Promise<{ allMeasureNames: string[] }>; + +export type SyncMeasuresField = typeof defaultSyncMeasuresField; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PibiRequestUpdates { + measureName?: string; + orderedAt?: Date; + completedAt?: Date | null; +} + +export type UpdatePibiRequestResult = + | { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string } + | { ok: false; error: string }; + +export interface UpdatePibiRequestInput { + id: bigint; + dealId: string; + updates: PibiRequestUpdates; + deps?: { + runUpdateTx?: RunUpdatePibiTx; + syncMeasuresField?: SyncMeasuresField; + }; +} + +// --------------------------------------------------------------------------- +// Default DB-backed implementation +// --------------------------------------------------------------------------- + +const defaultRunUpdateTx: RunUpdatePibiTx = async ({ id, dealId, updates }) => { + return await db.transaction(async (tx) => { + const result = await tx + .update(pibiRequests) + .set({ + ...(updates.measureName !== undefined && { measureName: updates.measureName }), + ...(updates.orderedAt !== undefined && { orderedAt: updates.orderedAt }), + ...(updates.completedAt !== undefined && { completedAt: updates.completedAt }), + }) + .where(and(eq(pibiRequests.id, id), eq(pibiRequests.hubspotDealId, dealId))) + .returning({ id: pibiRequests.id }); + + if (result.length === 0) { + throw new Error("PIBI request not found"); + } + + const allRows = await tx + .select({ measureName: pibiRequests.measureName }) + .from(pibiRequests) + .where(eq(pibiRequests.hubspotDealId, dealId)); + + return { allMeasureNames: allRows.map((r) => r.measureName) }; + }); +}; + +// --------------------------------------------------------------------------- +// Service entry-point +// --------------------------------------------------------------------------- + +export async function updatePibiRequest( + input: UpdatePibiRequestInput, +): Promise { + const runUpdateTx = input.deps?.runUpdateTx ?? defaultRunUpdateTx; + const syncMeasuresField = input.deps?.syncMeasuresField ?? defaultSyncMeasuresField; + + let txResult: { allMeasureNames: string[] }; + try { + txResult = await runUpdateTx({ + id: input.id, + dealId: input.dealId, + updates: input.updates, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update PIBI request"; + return { ok: false, error: message }; + } + + const syncResult = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: PIBI_ORDERED_TEXT_PROP, + measureNames: txResult.allMeasureNames, + }); + + if (syncResult.ok) { + return { ok: true, hubspotSync: "ok" }; + } + + return { + ok: true, + hubspotSync: "failed", + hubspotError: syncResult.error, + }; +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/OrganisationLinkCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/OrganisationLinkCard.tsx index 176047c..0dc5590 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/OrganisationLinkCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/OrganisationLinkCard.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Building2, CheckCircle2, Link2, Link2Off, AlertTriangle, Search } from "lucide-react"; +import { Building2, Link2, Link2Off, AlertTriangle, Search, CheckCircle2, PlusCircle } from "lucide-react"; import { Button } from "@/app/shadcn_components/ui/button"; import { Input } from "@/app/shadcn_components/ui/input"; import { @@ -18,9 +18,9 @@ type OrgSummary = { hubspotCompanyId: string | null; }; -async function fetchCurrentOrg(portfolioId: string): Promise { +async function fetchLinkedOrgs(portfolioId: string): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/organisation`); - if (!res.ok) throw new Error("Failed to fetch linked organisation"); + if (!res.ok) throw new Error("Failed to fetch linked organisations"); return res.json(); } @@ -33,25 +33,25 @@ async function fetchAllOrgs(): Promise { export default function OrganisationLinkCard({ portfolioId }: { portfolioId: string }) { const queryClient = useQueryClient(); - const [connectOpen, setConnectOpen] = useState(false); - const [disconnectOpen, setDisconnectOpen] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [disconnectTarget, setDisconnectTarget] = useState(null); const [selectedOrgId, setSelectedOrgId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [confirmed, setConfirmed] = useState(false); - // Current linked org - const { data: currentOrg, isLoading: loadingCurrent } = useQuery({ - queryKey: ["portfolio-org", portfolioId], - queryFn: () => fetchCurrentOrg(portfolioId), + const { data: linkedOrgs = [], isLoading: loadingLinked } = useQuery({ + queryKey: ["portfolio-orgs", portfolioId], + queryFn: () => fetchLinkedOrgs(portfolioId), }); - // All orgs — only fetched when connect modal is open const { data: allOrgs = [], isLoading: loadingOrgs } = useQuery({ queryKey: ["all-organisations"], queryFn: fetchAllOrgs, - enabled: connectOpen, + enabled: addOpen, }); + const linkedOrgIds = useMemo(() => new Set(linkedOrgs.map((o) => o.id)), [linkedOrgs]); + const connectMutation = useMutation({ mutationFn: async (organisationId: string) => { const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, { @@ -63,8 +63,8 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str return res.json(); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] }); - setConnectOpen(false); + queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] }); + setAddOpen(false); setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); @@ -72,116 +72,102 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str }); const disconnectMutation = useMutation({ - mutationFn: async () => { + mutationFn: async (organisationId: string) => { const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, { method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organisationId }), }); if (!res.ok) throw new Error("Failed to disconnect organisation"); return res.json(); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] }); - setDisconnectOpen(false); + queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] }); + setDisconnectTarget(null); }, }); - const filteredOrgs = useMemo( - () => - allOrgs.filter((o) => - (o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase()), - ), - [allOrgs, searchQuery], + const availableOrgs = useMemo( + () => allOrgs.filter((o) => !linkedOrgIds.has(o.id) && (o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase())), + [allOrgs, linkedOrgIds, searchQuery], ); const selectedOrg = allOrgs.find((o) => o.id === selectedOrgId) ?? null; + function openAdd() { + setAddOpen(true); + setSelectedOrgId(null); + setConfirmed(false); + setSearchQuery(""); + } + return (
{/* Header */} -
-
- -
-
-

Organisation Link

-

- Connect this portfolio to an organisation to enable live project tracking -

+
+
+
+ +
+
+

Organisation Links

+

+ Connect this portfolio to one or more organisations to enable live project tracking +

+
+
{/* Body */} -
- {loadingCurrent ? ( +
+ {loadingLinked ? (
- ) : currentOrg ? ( -
-
- -
-

{currentOrg.name ?? "Unnamed organisation"}

-

- Connected · HubSpot ID: {currentOrg.hubspotCompanyId ?? "—"} -

-
+ ) : linkedOrgs.length === 0 ? ( +
+
+
-
- +

No organisations linked

+
+ ) : ( + linkedOrgs.map((org) => ( +
+
+ +
+

{org.name ?? "Unnamed organisation"}

+

+ Connected · HubSpot ID: {org.hubspotCompanyId ?? "—"} +

+
+
-
- ) : ( -
-
-
- -
-

No organisation linked

-
- -
+ )) )}
- {/* ── Connect modal ─────────────────────────────────────────────── */} - { setConnectOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}> + {/* ── Add Organisation modal ─────────────────────────────────────────────── */} + { setAddOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}> - Connect Organisation + Add Organisation - {/* Search */}
- {/* Org list */}
{loadingOrgs ? (
Loading…
- ) : filteredOrgs.length === 0 ? ( -
No organisations found
+ ) : availableOrgs.length === 0 ? ( +
No organisations available
) : ( - filteredOrgs.map((org) => ( + availableOrgs.map((org) => (
- {/* Warning */}

@@ -226,7 +210,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str

- {/* Confirmation checkbox */}
- {/* ── Disconnect confirm dialog ──────────────────────────────────── */} - + {/* ── Disconnect confirm dialog ──────────────────────────────────────────── */} + { if (!v) setDisconnectTarget(null); }}> Disconnect organisation?

Are you sure you want to disconnect{" "} - {currentOrg?.name ?? "this organisation"}? - Live project tracking data will no longer be visible to portfolio viewers. + {disconnectTarget?.name ?? "this organisation"}? + Live project tracking data for this organisation will no longer be visible to portfolio viewers.

- ); })} @@ -432,6 +436,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS const [saveError, setSaveError] = useState(null); // The measure selected in the measure-select phase (empty = "not measure-specific") const [selectedMeasure, setSelectedMeasure] = useState(""); + // Bulk classify state + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkDocType, setBulkDocType] = useState(""); // ── Fetch existing unclassified files on mount ─────────────────────── @@ -581,14 +588,35 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS } const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId); - const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== ""); + const classifiedCount = getClassifiedCount(classifiableEntries); + const unclassifiedCount = getUnclassifiedIds(classifiableEntries).length; + + function handleBulkApply() { + if (!bulkDocType) return; + setQueue((prev) => applyBulkDocType(prev, selectedIds, bulkDocType)); + setSelectedIds(new Set()); + setBulkDocType(""); + } + + function toggleSelectId(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + + function handleSelectAllUnclassified() { + setSelectedIds(selectAllUnclassified(classifiableEntries)); + } async function handleSaveClassifications() { setSaveError(null); setIsSaving(true); try { + const toSave = classifiableEntries.filter((f) => f.docType !== ""); await saveClassifications( - classifiableEntries.map((f) => ({ + toSave.map((f) => ({ id: f.uploadedId!, fileType: f.docType, measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined, @@ -810,17 +838,43 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
)} + {/* Select-all row */} + {classifiableEntries.length > 0 && ( +
+ 0 && selectedIds.size === selectAllUnclassified(classifiableEntries).size} + onChange={() => { + const allUnclassified = selectAllUnclassified(classifiableEntries); + setSelectedIds(allUnclassified.size === selectedIds.size ? new Set() : allUnclassified); + }} + /> + +
+ )} + {/* File list with classification */}
{classifiableEntries.map((entry) => { const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null; const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null; const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : []; + const isChecked = selectedIds.has(entry.id); return ( -
+
{/* File info row */}
+ toggleSelectId(entry.id)} + />

{entry.displayName}

@@ -897,6 +951,51 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS })()}
+ {/* ── Bulk classify toolbar — shown when files are selected ── */} + {phase === "classify" && selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + + + Classify as: + + + +
+ )} + {phase === "loading" && ( @@ -930,17 +1029,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS - + {unclassifiedCount > 0 && ( +

+ {unclassifiedCount} unclassified — will stay in your queue +

)} - +
)} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index e73316f..8296d3b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -30,6 +30,7 @@ import type { DocumentDrawerState, DocStatusMap, RemovalStatusByDeal, + InstructedMeasuresByDeal, } from "./types"; export default function LiveTracker({ @@ -39,6 +40,7 @@ export default function LiveTracker({ docStatusMap, userCapability, approvalsByDeal, + instructedMeasuresByDeal, removalStatusByDeal, portfolioId, userRole, @@ -304,7 +306,9 @@ export default function LiveTracker({
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx index 9259fc4..8c8d4ce 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -1,8 +1,7 @@ "use client"; -import React, { useMemo, useState } from "react"; +import React, { useMemo, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; import { Table, TableBody, @@ -13,34 +12,28 @@ import { } from "@/app/shadcn_components/ui/table"; import { Input } from "@/app/shadcn_components/ui/input"; import { Badge } from "@/app/shadcn_components/ui/badge"; -import { Search, ChevronDown, ChevronRight } from "lucide-react"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; +import { Search, CheckSquare, ListChecks, Loader2, X } from "lucide-react"; import { STAGE_COLORS } from "./types"; import type { ClassifiedDeal, ApprovalsByDeal } from "./types"; import { parseMeasures } from "@/app/lib/parseMeasures"; +import { filterMeasureRows } from "./measureFilters"; +import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; -type AuditEvent = { - id: string; - hubspotDealId: string; - measureName: string; - action: string; // 'approved' | 'unapproved' - actedByEmail: string; - actedByName: string | null; - actedAt: string; // ISO string -}; +type Mode = "chip-click" | "instruct"; type Props = { data: ClassifiedDeal[]; approvalsByDeal: ApprovalsByDeal; + instructedMeasuresByDeal: Record; portfolioId: string; + isApprover: boolean; }; -function ApprovalStatus({ - proposed, - approved, -}: { - proposed: string[]; - approved: string[]; -}) { +// ── Approval status badge ──────────────────────────────────────────────────── + +function ApprovalStatus({ proposed, approved }: { proposed: string[]; approved: string[] }) { if (proposed.length === 0) return null; const approvedSet = new Set(approved); const approvedCount = proposed.filter((m) => approvedSet.has(m)).length; @@ -66,103 +59,295 @@ function ApprovalStatus({ ); } -function formatDate(iso: string) { - return new Date(iso).toLocaleString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} +// ── Bulk approve modal ─────────────────────────────────────────────────────── -function ActivityLog({ - dealId, - portfolioId, -}: { - dealId: string; +type BulkApproveModalProps = { + selected: Array<{ dealId: string; dealname: string | null; measureName: string }>; portfolioId: string; -}) { - const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({ - queryKey: ["approvalEvents", portfolioId, dealId], - queryFn: async () => { - const res = await fetch( - `/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`, - ); - if (!res.ok) throw new Error("Failed to fetch events"); - return res.json(); - }, - staleTime: 30_000, - }); + onClose: () => void; + onSuccess: () => void; +}; - if (isLoading) { - return ( -

Loading activity…

- ); - } +function BulkApproveModal({ selected, portfolioId, onClose, onSuccess }: BulkApproveModalProps) { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); - const events = data?.events ?? []; + const groupedByDeal = useMemo(() => { + const map = new Map(); + for (const item of selected) { + const existing = map.get(item.dealId); + if (existing) { + existing.measures.push(item.measureName); + } else { + map.set(item.dealId, { dealname: item.dealname, measures: [item.measureName] }); + } + } + return map; + }, [selected]); - if (events.length === 0) { - return ( -

No activity yet.

- ); + async function handleConfirm() { + setSubmitting(true); + setError(null); + try { + const res = await fetch(`/api/portfolio/${portfolioId}/bulk-approvals`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + changes: selected.map((s) => ({ + hubspotDealId: s.dealId, + measureName: s.measureName, + approved: true, + })), + }), + }); + const data = await res.json(); + if (!res.ok || !data.ok) { + setError(data.error ?? "Failed to approve measures"); + return; + } + onSuccess(); + } catch { + setError("Network error — please try again"); + } finally { + setSubmitting(false); + } } return ( -
- {events.map((e) => ( -
- - {e.action === "approved" ? "Approved" : "Unapproved"} - - {e.measureName} - · - - {e.actedByName ?? e.actedByEmail} - - · - {formatDate(e.actedAt)} +
+
+
+

Confirm bulk approval

+
- ))} + +

+ Approving {selected.length} measure{selected.length !== 1 ? "s" : ""} across{" "} + {groupedByDeal.size} propert{groupedByDeal.size !== 1 ? "ies" : "y"}. +

+ +
+ {[...groupedByDeal.entries()].map(([dealId, { dealname, measures }]) => ( +
+

{dealname ?? dealId}

+
+ {measures.map((m) => ( + + {m} + + ))} +
+
+ ))} +
+ + {error &&

{error}

} + +
+ + +
+
); } +// ── Bulk instruct modal ────────────────────────────────────────────────────── + +type BulkInstructModalProps = { + selectedDealIds: string[]; + deals: ClassifiedDeal[]; + portfolioId: string; + onClose: () => void; + onSuccess: () => void; +}; + +function BulkInstructModal({ selectedDealIds, deals, portfolioId, onClose, onSuccess }: BulkInstructModalProps) { + const [selectedMeasures, setSelectedMeasures] = useState>(new Set()); + const [confirmText, setConfirmText] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const selectedDeals = useMemo( + () => deals.filter((d) => selectedDealIds.includes(d.dealId)), + [deals, selectedDealIds], + ); + + function toggleMeasure(name: string) { + setSelectedMeasures((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + const canSubmit = selectedMeasures.size > 0 && confirmText.trim() === "confirm" && !submitting; + + async function handleSubmit() { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + const res = await fetch(`/api/portfolio/${portfolioId}/bulk-instructed-measures`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + deals: selectedDealIds.map((dealId) => ({ + dealId, + measureNames: [...selectedMeasures], + })), + }), + }); + const data = await res.json(); + if (!res.ok || !data.ok) { + setError(data.error ?? "Failed to instruct measures"); + return; + } + onSuccess(); + } catch { + setError("Network error — please try again"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+

Bulk instruct measures

+ +
+ +

+ Instructing on {selectedDeals.length} propert{selectedDeals.length !== 1 ? "ies" : "y"}. + Select measures to add to all selected deals. +

+ +
+
+ {MEASURE_NAMES.map((name) => ( + + ))} +
+
+ + {selectedMeasures.size > 0 && ( +
+

+ Type confirm to proceed +

+ setConfirmText(e.target.value)} + placeholder="confirm" + className="h-8 text-sm" + /> +
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} + +// ── Main component ─────────────────────────────────────────────────────────── + export default function MeasuresTable({ data, approvalsByDeal, + instructedMeasuresByDeal, portfolioId, + isApprover, }: Props) { const router = useRouter(); - const [search, setSearch] = useState(""); - const [expandedRows, setExpandedRows] = useState>(new Set()); + const [isPending, startTransition] = useTransition(); + + const [search, setSearch] = useState(""); + const [mode, setMode] = useState("chip-click"); + + // Chip-click mode: Set<"dealId::measureName"> + const [selectedChips, setSelectedChips] = useState>(new Set()); + + // Instruct mode: Set + const [selectedRows, setSelectedRows] = useState>(new Set()); + + const [showApproveModal, setShowApproveModal] = useState(false); + const [showInstructModal, setShowInstructModal] = useState(false); - // Filter to only properties with proposed measures const dealsWithMeasures = useMemo( - () => data.filter((d) => d.proposedMeasures), - [data], + () => data.filter((d) => d.proposedMeasures || (instructedMeasuresByDeal[d.dealId]?.length ?? 0) > 0), + [data, instructedMeasuresByDeal], ); - const filtered = useMemo(() => { - const q = search.toLowerCase(); - if (!q) return dealsWithMeasures; - return dealsWithMeasures.filter( - (d) => - d.dealname?.toLowerCase().includes(q) || - d.landlordPropertyId?.toLowerCase().includes(q) || - d.proposedMeasures?.toLowerCase().includes(q), - ); - }, [dealsWithMeasures, search]); + const filtered = useMemo( + () => filterMeasureRows(dealsWithMeasures, instructedMeasuresByDeal, search), + [dealsWithMeasures, instructedMeasuresByDeal, search], + ); - function toggleRowExpand(dealId: string) { - setExpandedRows((prev) => { + function switchMode(next: Mode) { + setMode(next); + setSelectedChips(new Set()); + setSelectedRows(new Set()); + } + + function toggleChip(dealId: string, measureName: string) { + const key = `${dealId}::${measureName}`; + setSelectedChips((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + function toggleRow(dealId: string) { + setSelectedRows((prev) => { const next = new Set(prev); if (next.has(dealId)) next.delete(dealId); else next.add(dealId); @@ -170,6 +355,45 @@ export default function MeasuresTable({ }); } + function toggleAllFiltered() { + const allIds = filtered.map((d) => d.dealId); + const allSelected = allIds.every((id) => selectedRows.has(id)); + if (allSelected) { + setSelectedRows((prev) => { + const next = new Set(prev); + allIds.forEach((id) => next.delete(id)); + return next; + }); + } else { + setSelectedRows((prev) => { + const next = new Set(prev); + allIds.forEach((id) => next.add(id)); + return next; + }); + } + } + + const selectedChipItems = useMemo( + () => + [...selectedChips].map((key) => { + const [dealId, measureName] = key.split("::"); + const deal = data.find((d) => d.dealId === dealId); + return { dealId, dealname: deal?.dealname ?? null, measureName }; + }), + [selectedChips, data], + ); + + const allFilteredSelected = + filtered.length > 0 && filtered.every((d) => selectedRows.has(d.dealId)); + + function handleActionSuccess() { + setShowApproveModal(false); + setShowInstructModal(false); + setSelectedChips(new Set()); + setSelectedRows(new Set()); + startTransition(() => router.refresh()); + } + if (dealsWithMeasures.length === 0) { return (
@@ -180,9 +404,11 @@ export default function MeasuresTable({ ); } + const colSpan = mode === "instruct" ? 7 : 6; + return (
- {/* Toolbar */} + {/* ── Toolbar ──────────────────────────────────────────────────── */}
@@ -193,22 +419,100 @@ export default function MeasuresTable({ className="pl-9 h-9 text-sm" />
+
{filtered.length} of {dealsWithMeasures.length} properties - - · Click a row to open property - + + {isApprover && ( + <> + + + + )}
- {/* Table */} -
+ {/* ── Action bar ───────────────────────────────────────────────── */} + {isApprover && mode === "chip-click" && selectedChips.size > 0 && ( +
+ + {selectedChips.size} measure{selectedChips.size !== 1 ? "s" : ""} selected + + + +
+ )} + + {isApprover && mode === "instruct" && selectedRows.size > 0 && ( +
+ + {selectedRows.size} propert{selectedRows.size !== 1 ? "ies" : "y"} selected + + + +
+ )} + + {/* ── Table ────────────────────────────────────────────────────── */} +
+ {isPending && ( +
+ +
+ )} - + {mode === "instruct" && ( + + + + )} Address @@ -216,7 +520,13 @@ export default function MeasuresTable({ Stage - Proposed Measures + Proposed + + + Instructed + + + Tech Approved Status @@ -226,121 +536,161 @@ export default function MeasuresTable({ {filtered.map((deal) => { const proposed = parseMeasures(deal.proposedMeasures); + const instructed = instructedMeasuresByDeal[deal.dealId] ?? []; + const techApproved = parseMeasures(deal.technicalApprovedMeasuresForInstall); const approvedForDeal = approvalsByDeal[deal.dealId] ?? []; const approvedSet = new Set(approvedForDeal); const stageColor = STAGE_COLORS[deal.displayStage]; - const isExpanded = expandedRows.has(deal.dealId); + const isRowSelected = selectedRows.has(deal.dealId); const dealPageUrl = `/portfolio/${portfolioId}/your-projects/live/${deal.dealId}?tab=works`; - const handleRowClick = () => { + const handleRowClick = (e: React.MouseEvent) => { + // Don't navigate if clicking a chip or checkbox + const target = e.target as HTMLElement; + if (target.closest("[data-chip]") || target.closest("[data-checkbox]")) return; router.push(dealPageUrl); }; - const handleRowKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - router.push(dealPageUrl); - } - }; return ( - - - {/* Expand toggle */} - - + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + router.push(dealPageUrl); + } + }} + className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${ + isRowSelected ? "bg-indigo-50/40" : "" + }`} + > + {/* Row checkbox (instruct mode only) */} + {mode === "instruct" && ( + + toggleRow(deal.dealId)} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4" + aria-label={`Select ${deal.dealname ?? deal.dealId}`} + /> + )} - {/* Address */} - -
- {deal.dealname ?? "—"} + {/* Address */} + +
+ {deal.dealname ?? "—"} +
+ {deal.landlordPropertyId && ( +
+ {deal.landlordPropertyId}
- {deal.landlordPropertyId && ( -
- {deal.landlordPropertyId} -
- )} -
+ )} + - {/* Stage */} - - - - {deal.displayStage} - - + {/* Stage */} + + + + {deal.displayStage} + + - {/* Proposed measures — read-only; click the row to approve in the drawer */} - -
- {proposed.map((measure) => { - const isApproved = approvedSet.has(measure); - return ( - +
+ {proposed.map((measure) => { + const chipKey = `${deal.dealId}::${measure}`; + const isSelected = selectedChips.has(chipKey); + const isApproved = approvedSet.has(measure); + const clickable = isApprover && mode === "chip-click"; + + return ( + { e.stopPropagation(); toggleChip(deal.dealId, measure); } : undefined} + className={`px-2 py-1 rounded-full text-xs border transition-all ${ + isSelected + ? "bg-emerald-100 border-emerald-400 text-emerald-800 ring-2 ring-emerald-300" + : isApproved ? "bg-emerald-50 border-emerald-200 text-emerald-700" : "bg-gray-50 border-gray-200 text-gray-600" - }`} - > - {measure} - - ); - })} -
- + } ${clickable ? "cursor-pointer hover:border-emerald-400" : ""}`} + > + {measure} +
+ ); + })} +
+
- {/* Status */} - - - + {/* Instructed measures */} + +
+ {instructed.map((measure) => ( + + {measure} + + ))} +
+
- + {/* Tech approved */} + +
+ {techApproved.map((measure) => ( + + {measure} + + ))} +
+
- {/* Expandable activity log row */} - {isExpanded && ( - - -
-

- Activity log -

- -
-
-
- )} - + {/* Approval status */} + + + + ); })}
+ {/* ── Modals ───────────────────────────────────────────────────── */} + {showApproveModal && ( + setShowApproveModal(false)} + onSuccess={handleActionSuccess} + /> + )} + + {showInstructModal && ( + setShowInstructModal(false)} + onSuccess={handleActionSuccess} + /> + )}
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx new file mode 100644 index 0000000..5aa689a --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx @@ -0,0 +1,699 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useReactTable, + getCoreRowModel, + createColumnHelper, + flexRender, + type RowData, +} from "@tanstack/react-table"; +import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; +import { + toDateInputValue, + dateInputToIso, + formatDate, +} from "@/app/lib/pibiSectionHelpers"; +import type { PibiRow } from "@/app/lib/pibiSectionHelpers"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/app/shadcn_components/ui/tooltip"; + +// ── TableMeta augmentation ──────────────────────────────────────────────────── + +declare module "@tanstack/react-table" { + interface TableMeta { + pibi?: PibiTableMeta; + } +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface EditableRow { + id: string; + isNew: boolean; + measureName: string; + orderedAt: string; // yyyy-mm-dd or "" + completedAt: string; // yyyy-mm-dd or "" + isDirty: boolean; + isSaving: boolean; + error: string | null; +} + +interface NewRowData { + id: string; + measureName: string; + orderedAt: string; + completedAt: string; +} + +interface PibiTableMeta { + canEdit: boolean; + approvedMeasures: string[]; + proposedMeasures: string[]; + updateField( + id: string, + field: "measureName" | "orderedAt" | "completedAt", + value: string, + ): void; + saveRow(id: string): void; + deleteRow(id: string): void; +} + +// ── New row ID counter ──────────────────────────────────────────────────────── + +let newRowSeq = 0; +function nextNewId() { + return `new-${++newRowSeq}`; +} + +// ── Scope badge ─────────────────────────────────────────────────────────────── + +function ScopeBadge({ + measure, + approved, + proposed, +}: { + measure: string; + approved: string[]; + proposed: string[]; +}) { + if (approved.includes(measure)) { + return ( + + + + Approved + + + This measure has been technically approved for installation + + ); + } + if (proposed.includes(measure)) { + return ( + + + + Proposed + + + This measure has been proposed but not yet approved + + ); + } + return null; +} + +// ── Column definitions ──────────────────────────────────────────────────────── + +const columnHelper = createColumnHelper(); + +const COLUMNS = [ + columnHelper.accessor("measureName", { + header: "Measure", + cell: ({ row, table }) => { + const meta = table.options.meta!.pibi!; + const r = row.original; + return ( +
+ {meta.canEdit ? ( + + ) : ( + {r.measureName} + )} + +
+ ); + }, + }), + + columnHelper.accessor("orderedAt", { + header: "Ordered", + cell: ({ row, table }) => { + const meta = table.options.meta!.pibi!; + const r = row.original; + if (!meta.canEdit) { + return ( + + {formatDate(r.orderedAt)} + + ); + } + return ( + + meta.updateField(r.id, "orderedAt", e.target.value) + } + disabled={r.isSaving} + data-testid={`pibi-ordered-date-${r.id}`} + className="rounded border border-gray-200 px-1.5 py-0.5 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40 w-[110px]" + /> + ); + }, + }), + + columnHelper.accessor("completedAt", { + header: "Completed", + cell: ({ row, table }) => { + const meta = table.options.meta!.pibi!; + const r = row.original; + if (!meta.canEdit) { + return r.completedAt ? ( + + {formatDate(r.completedAt)} + + ) : ( + + ); + } + return ( + + meta.updateField(r.id, "completedAt", e.target.value) + } + disabled={r.isSaving} + data-testid={`pibi-completed-date-${r.id}`} + className="rounded border border-gray-200 px-1.5 py-0.5 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40 w-[110px]" + /> + ); + }, + }), + + columnHelper.display({ + id: "actions", + header: "", + cell: ({ row, table }) => { + const meta = table.options.meta!.pibi!; + if (!meta.canEdit) return null; + const r = row.original; + return ( +
+ + + {r.error && ( + {r.error} + )} +
+ ); + }, + }), +]; + +// ── Main component ──────────────────────────────────────────────────────────── + +export interface PibiSectionProps { + dealId: string; + portfolioId: string; + proposedMeasures: string[]; + canEdit: boolean; +} + +export function PibiSection({ + dealId, + portfolioId, + proposedMeasures, + canEdit, +}: PibiSectionProps) { + const queryClient = useQueryClient(); + + // Local edit state for existing rows + const [localEdits, setLocalEdits] = useState< + Record< + string, + Partial<{ measureName: string; orderedAt: string; completedAt: string }> + > + >({}); + + // Unsaved new rows + const [newRows, setNewRows] = useState([]); + + // Row IDs currently being saved or deleted + const [savingIds, setSavingIds] = useState>(new Set()); + + // Per-row error messages + const [rowErrors, setRowErrors] = useState>({}); + + // ── Server data ─────────────────────────────────────────────────────────── + + const { data, isLoading } = useQuery<{ pibiRequests: PibiRow[] }>({ + queryKey: ["pibiRequests", portfolioId, dealId], + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests?dealId=${encodeURIComponent(dealId)}`, + ); + if (!res.ok) throw new Error("Failed to fetch PIBI requests"); + return res.json(); + }, + staleTime: 30_000, + }); + + const { data: measureData } = useQuery<{ + approvedMeasures: string[]; + instructedMeasures: string[]; + }>({ + queryKey: ["pibiMeasures", portfolioId, dealId], + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`, + ); + if (!res.ok) throw new Error("Failed to fetch measures"); + return res.json(); + }, + staleTime: 60_000, + }); + + const approvedMeasures = useMemo( + () => [ + ...(measureData?.approvedMeasures ?? []), + ...(measureData?.instructedMeasures ?? []), + ], + [measureData], + ); + + // ── Derived table rows ──────────────────────────────────────────────────── + + const tableRows: EditableRow[] = useMemo(() => { + const serverRows = (data?.pibiRequests ?? []).map( + (row): EditableRow => ({ + id: row.id, + isNew: false, + measureName: localEdits[row.id]?.measureName ?? row.measureName, + orderedAt: + localEdits[row.id]?.orderedAt ?? toDateInputValue(row.orderedAt), + completedAt: + localEdits[row.id]?.completedAt ?? + toDateInputValue(row.completedAt), + isDirty: !!localEdits[row.id], + isSaving: savingIds.has(row.id), + error: rowErrors[row.id] ?? null, + }), + ); + + const pendingRows = newRows.map( + (row): EditableRow => ({ + id: row.id, + isNew: true, + measureName: row.measureName, + orderedAt: row.orderedAt, + completedAt: row.completedAt, + isDirty: true, + isSaving: savingIds.has(row.id), + error: rowErrors[row.id] ?? null, + }), + ); + + return [...serverRows, ...pendingRows]; + }, [data, localEdits, newRows, savingIds, rowErrors]); + + // ── Handlers ────────────────────────────────────────────────────────────── + + const updateField = useCallback( + ( + id: string, + field: "measureName" | "orderedAt" | "completedAt", + value: string, + ) => { + if (id.startsWith("new-")) { + setNewRows((prev) => + prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)), + ); + } else { + setLocalEdits((prev) => ({ + ...prev, + [id]: { ...prev[id], [field]: value }, + })); + } + }, + [], + ); + + const saveRow = useCallback( + async (id: string) => { + const row = tableRows.find((r) => r.id === id); + if (!row) return; + + setSavingIds((prev) => new Set(prev).add(id)); + setRowErrors((prev) => { + const n = { ...prev }; + delete n[id]; + return n; + }); + + try { + let res: Response; + + if (row.isNew) { + res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dealId, + measureNames: [row.measureName], + orderedAt: row.orderedAt + ? new Date(`${row.orderedAt}T00:00:00.000Z`).toISOString() + : undefined, + }), + }, + ); + } else { + res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests/${id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dealId, + measureName: row.measureName, + ...(row.orderedAt && { + orderedAt: dateInputToIso(row.orderedAt), + }), + completedAt: row.completedAt + ? dateInputToIso(row.completedAt) + : null, + }), + }, + ); + } + + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error( + typeof json.error === "string" ? json.error : "Save failed", + ); + } + + if (row.isNew) { + setNewRows((prev) => prev.filter((r) => r.id !== id)); + } else { + setLocalEdits((prev) => { + const n = { ...prev }; + delete n[id]; + return n; + }); + } + + await queryClient.invalidateQueries({ + queryKey: ["pibiRequests", portfolioId, dealId], + }); + } catch (err) { + setRowErrors((prev) => ({ + ...prev, + [id]: err instanceof Error ? err.message : "Save failed", + })); + } finally { + setSavingIds((prev) => { + const n = new Set(prev); + n.delete(id); + return n; + }); + } + }, + [tableRows, dealId, portfolioId, queryClient], + ); + + const deleteRow = useCallback( + async (id: string) => { + const row = tableRows.find((r) => r.id === id); + if (!row) return; + + if (row.isNew) { + setNewRows((prev) => prev.filter((r) => r.id !== id)); + return; + } + + setSavingIds((prev) => new Set(prev).add(id)); + setRowErrors((prev) => { + const n = { ...prev }; + delete n[id]; + return n; + }); + + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests/${id}?dealId=${encodeURIComponent(dealId)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Delete failed"); + + await queryClient.invalidateQueries({ + queryKey: ["pibiRequests", portfolioId, dealId], + }); + } catch (err) { + setRowErrors((prev) => ({ + ...prev, + [id]: err instanceof Error ? err.message : "Delete failed", + })); + } finally { + setSavingIds((prev) => { + const n = new Set(prev); + n.delete(id); + return n; + }); + } + }, + [tableRows, dealId, portfolioId, queryClient], + ); + + function addRow() { + const today = new Date().toISOString().slice(0, 10); + setNewRows((prev) => [ + ...prev, + { + id: nextNewId(), + measureName: MEASURE_NAMES[0], + orderedAt: today, + completedAt: "", + }, + ]); + } + + async function markAllComplete() { + const now = new Date().toISOString(); + const incomplete = tableRows.filter((r) => !r.isNew && !r.completedAt); + await Promise.all( + incomplete.map((r) => + fetch(`/api/portfolio/${portfolioId}/pibi-requests/${r.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dealId, completedAt: now }), + }), + ), + ); + await queryClient.invalidateQueries({ + queryKey: ["pibiRequests", portfolioId, dealId], + }); + } + + // ── Table instance ──────────────────────────────────────────────────────── + + const table = useReactTable({ + data: tableRows, + columns: COLUMNS, + getCoreRowModel: getCoreRowModel(), + meta: { + pibi: { + canEdit, + approvedMeasures, + proposedMeasures, + updateField, + saveRow, + deleteRow, + }, + }, + }); + + // ── Render ──────────────────────────────────────────────────────────────── + + if (isLoading) { + return ( +
+ Loading PIBIs… +
+ ); + } + + const hasIncomplete = tableRows.some((r) => !r.isNew && !r.completedAt); + + return ( + +
+ {/* Header actions */} +
+ {canEdit && hasIncomplete && ( + + )} + {canEdit && ( + + )} +
+ + {/* Empty state */} + {tableRows.length === 0 && ( +
+

+ No PIBIs logged yet +

+ {canEdit && ( + + )} +
+ )} + + {/* Table */} + {tableRows.length > 0 && ( +
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {flexRender( + h.column.columnDef.header, + h.getContext(), + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ )} +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index fd9fe62..9295690 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -1,8 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { X, ChevronRight, ChevronDown, AlertTriangle } from "lucide-react"; import { Drawer, DrawerClose, @@ -10,1905 +9,39 @@ import { DrawerHeader, DrawerTitle, } from "@/app/shadcn_components/ui/drawer"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/app/shadcn_components/ui/dialog"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/app/shadcn_components/ui/tooltip"; -import { STAGE_COLORS } from "./types"; -import type { ClassifiedDeal, PortfolioCapabilityType, RemovalRequest } from "./types"; import { parseMeasures } from "@/app/lib/parseMeasures"; -import { ApprovalConfirmDialog } from "./ApprovalConfirmDialog"; -import type { PendingDiff } from "./ApprovalConfirmDialog"; -import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings"; +import type { ClassifiedDeal, PortfolioCapabilityType } from "./types"; +import { StageBadge } from "./ui"; +import { + type DrawerSection, + SECTION_TITLES, + InfoRow, + SectionHeader, + MilestoneTimeline, + formatDate, +} from "./deal-detail/primitives"; +import { MeasureApprovalEditor } from "./deal-detail/MeasureApprovalEditor"; +import { InstructMeasureEditor } from "./deal-detail/pibi/InstructMeasureEditor"; +import { PibiSurveysTabContent } from "./deal-detail/PibiSurveysTabContent"; +import { ActivityLog } from "./ActivityLog"; -// Sections the caller can request focus on. Used by entry-points like the -// Measures table row click that should land the user on a specific tab. -export type DrawerSection = - | "survey" - | "measures" - | "pibi" - | "domna" - | "halted" - | "technical"; +type DrawerTab = "overview" | "works" | "pibi-surveys"; -// The four tabs inside the drawer. -type DrawerTab = "overview" | "works" | "pibi" | "survey-admin"; - -// Maps each focusable section to the tab that contains it. const SECTION_TO_TAB: Record = { survey: "overview", measures: "works", technical: "works", - pibi: "pibi", - domna: "survey-admin", - halted: "survey-admin", + pibi: "pibi-surveys", + domna: "pibi-surveys", }; const TAB_LABELS: Record = { overview: "Overview", works: "Works", - pibi: "PIBI", - "survey-admin": "Survey & Admin", + "pibi-surveys": "PIBIs & Surveys", }; -// ----------------------------------------------------------------------- -// Removal request section -// ----------------------------------------------------------------------- -export const WRITE_ROLES = ["creator", "admin", "write"]; - -export function RemovalRequestSection({ - dealId, - portfolioId, - userRole, - userCapability, -}: { - dealId: string; - portfolioId: string; - userRole: string; - userCapability: PortfolioCapabilityType; -}) { - const queryClient = useQueryClient(); - const [dialogType, setDialogType] = useState<"removal" | "re_addition" | null>(null); - const [reason, setReason] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [reviewing, setReviewing] = useState(false); - const [error, setError] = useState(null); - - const canRequest = WRITE_ROLES.includes(userRole); - const isApprover = userCapability.includes("approver"); - - const { data, isLoading } = useQuery<{ requests: RemovalRequest[] }>({ - queryKey: ["removalRequests", portfolioId, dealId], - queryFn: async () => { - const res = await fetch( - `/api/portfolio/${portfolioId}/removal-requests?dealId=${dealId}`, - ); - if (!res.ok) throw new Error("Failed to fetch removal requests"); - return res.json(); - }, - staleTime: 30_000, - }); - - const latest = data?.requests?.[0] ?? null; - - // Derive effective state from the most recent request - type EffectiveState = "active" | "pending_removal" | "removed" | "pending_re_addition"; - const effectiveState: EffectiveState = (() => { - if (!latest) return "active"; - if (latest.status === "pending") { - return latest.type === "re_addition" ? "pending_re_addition" : "pending_removal"; - } - if (latest.type === "removal" && latest.status === "approved") return "removed"; - if (latest.type === "re_addition" && latest.status === "declined") return "removed"; - return "active"; - })(); - - const pendingRequest = latest?.status === "pending" ? latest : null; - const latestResolvedRequest = latest?.status !== "pending" ? latest : null; - - function closeDialog() { - setDialogType(null); - setReason(""); - setError(null); - } - - async function handleSubmit() { - if (!reason.trim() || !dialogType) return; - setSubmitting(true); - setError(null); - try { - const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim(), type: dialogType }), - }); - if (!res.ok) { - const json = await res.json().catch(() => ({})); - setError(json.error ?? "Failed to submit request"); - return; - } - closeDialog(); - queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] }); - } finally { - setSubmitting(false); - } - } - - async function handleReview(requestId: string, action: "approved" | "declined") { - setReviewing(true); - setError(null); - try { - const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requestId: Number(requestId), action }), - }); - if (!res.ok) { - const json = await res.json().catch(() => ({})); - setError(json.error ?? "Failed to review request"); - return; - } - queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] }); - } finally { - setReviewing(false); - } - } - - function resolvedLabel(req: RemovalRequest): string { - if (req.type === "re_addition") { - return req.status === "approved" ? "Re-addition Approved" : "Re-addition Declined"; - } - return req.status === "approved" ? "Removal Approved" : "Removal Declined"; - } - - if (isLoading) { - return

Loading…

; - } - - return ( -
- {error && ( -

{error}

- )} - - {/* Pending request — visible to everyone */} - {pendingRequest && ( -
-
- - {pendingRequest.type === "re_addition" ? "Pending Re-addition Request" : "Pending Removal Request"} - -
-

{pendingRequest.reason}

-

- Requested by {pendingRequest.requestedByEmail} - {" · "} - {formatDateTime(pendingRequest.requestedAt)} -

- {isApprover && ( -
- - -
- )} -
- )} - - {/* Most recent resolved request */} - {latestResolvedRequest && ( -
-
- - {resolvedLabel(latestResolvedRequest)} - -
-

{latestResolvedRequest.reason}

-

- Requested by {latestResolvedRequest.requestedByEmail} - {" · "} - {formatDateTime(latestResolvedRequest.requestedAt)} -

- {latestResolvedRequest.reviewedByEmail && ( -

- {latestResolvedRequest.status === "approved" ? "Approved" : "Declined"} by{" "} - {latestResolvedRequest.reviewedByEmail} - {latestResolvedRequest.reviewedAt && ` · ${formatDateTime(latestResolvedRequest.reviewedAt)}`} -

- )} -
- )} - - {/* Action buttons — shown when no pending request */} - {!pendingRequest && ( - <> - {effectiveState === "active" && ( - - - - - - - - {!canRequest && ( - - Not available with read-only permissions - - )} - - - )} - - {effectiveState === "removed" && ( - - - - - - - - {!canRequest && ( - - Not available with read-only permissions - - )} - - - )} - - )} - - {/* Shared dialog for removal and re-addition requests */} - { if (!v) closeDialog(); }}> - - - - {dialogType === "re_addition" ? "Request Re-addition to Project" : "Request Removal from Project"} - - -
-

- {dialogType === "re_addition" - ? "Please provide a reason why this property should be re-added to the project. This will be recorded for audit purposes." - : "Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes."} -

-