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/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/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."} -

-