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..4557e04 --- /dev/null +++ b/cypress/e2e/live-tracking/pibi-section.cy.js @@ -0,0 +1,228 @@ +/** + * Live Tracking — PibiSection (replaces pibi-dates.cy.js + pibi-measures.cy.js) + * + * Tests the approver flow for the new per-measure PIBI request log: + * 1. Empty state renders with a "Log first PIBI" prompt + * 2. Approver can open the log form, pick measures + date, and submit + * 3. Submitted batch appears as a group with orderedAt header + * 4. Approver can mark a row complete / undo it + * 5. Approver can delete a row + * + * Requires LIVE_PORTFOLIO_SLUG env var; skipped otherwise. + * All network calls are intercepted so no real DB / HubSpot round-trips occur. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); +const TARGET_DEAL_NAME = Cypress.env("LIVE_PIBI_DEAL_NAME"); + +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: { approvedMeasures, instructedMeasures } }, + ).as("getPibiMeasures"); +} + +function stubPost(response = { ok: true, insertedCount: 2, 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 openDrawerAtPibiSection() { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + 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"); + cy.get("[data-testid=drawer-tab-pibi-surveys]").click(); + cy.get("[data-testid=drawer-tab-panel-pibi-surveys]").should("be.visible"); +} + +describe("PibiSection", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log("LIVE_PORTFOLIO_SLUG not set — skipping PibiSection specs"); + this.skip(); + } + }); + + beforeEach(() => { + stubMeasures(); + }); + + // ── Empty state ────────────────────────────────────────────────────────────── + + it("shows empty state with Log first PIBI prompt when no requests exist", () => { + stubGet([]); + openDrawerAtPibiSection(); + 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"); + }); + + // ── Log form ───────────────────────────────────────────────────────────────── + + it("opens log form when approver clicks Log PIBI button", () => { + stubGet([]); + openDrawerAtPibiSection(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=log-pibi-button]").click(); + cy.get("[data-testid=pibi-log-form]").should("be.visible"); + cy.get("[data-testid=pibi-order-date-input]").should("be.visible"); + }); + + it("submits selected measures and order date to POST endpoint", () => { + stubGet([]); + // After POST, return a batch so the list re-fetches populated + const orderedAt = "2026-05-06T00:00:00.000Z"; + stubPost({ ok: true, insertedCount: 2, hubspotSync: "ok" }); + cy.intercept( + "GET", + `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests*`, + { body: { pibiRequests: [ + { id: "1", measureName: "ASHP", orderedAt, completedAt: null }, + { id: "2", measureName: "CWI", orderedAt, completedAt: null }, + ] } }, + ).as("getPibiRequestsAfter"); + + openDrawerAtPibiSection(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=log-pibi-button]").click(); + + cy.get("[data-testid=pibi-measure-checkbox-ASHP]").check(); + cy.get("[data-testid=pibi-measure-checkbox-CWI]").check(); + cy.get("[data-testid=pibi-order-date-input]").clear().type("2026-05-06"); + + cy.get("[data-testid=pibi-submit-button]").click(); + + cy.wait("@postPibiRequest").then((interception) => { + expect(interception.request.body.measureNames).to.include.members(["ASHP", "CWI"]); + expect(interception.request.body.orderedAt).to.include("2026-05-06"); + }); + }); + + // ── Batch display ───────────────────────────────────────────────────────────── + + it("renders a batch group with orderedAt header and measure rows", () => { + const orderedAt = "2026-05-01T00:00:00.000Z"; + stubGet([ + { id: "1", measureName: "ASHP", orderedAt, completedAt: null }, + { id: "2", measureName: "CWI", orderedAt, completedAt: null }, + ]); + openDrawerAtPibiSection(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-batch-group]").should("have.length", 1); + cy.get("[data-testid=pibi-batch-group]").should("contain.text", "01 May 2026"); + cy.get("[data-testid=pibi-row-1]").should("contain.text", "ASHP"); + cy.get("[data-testid=pibi-row-2]").should("contain.text", "CWI"); + }); + + it("renders two separate batch groups for different orderedAt values", () => { + stubGet([ + { id: "1", measureName: "ASHP", orderedAt: "2026-04-01T00:00:00.000Z", completedAt: null }, + { id: "2", measureName: "CWI", orderedAt: "2026-05-01T00:00:00.000Z", completedAt: null }, + ]); + openDrawerAtPibiSection(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-batch-group]").should("have.length", 2); + }); + + // ── Complete / undo ─────────────────────────────────────────────────────────── + + it("marks a row complete when approver clicks Complete", () => { + const orderedAt = "2026-05-01T00:00:00.000Z"; + stubGet([{ id: "10", measureName: "ASHP", orderedAt, completedAt: null }]); + stubPatch("10"); + openDrawerAtPibiSection(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-complete-button-10]").click(); + cy.wait("@patchPibiRequest-10").then((interception) => { + expect(interception.request.body.completedAt).to.not.be.null; + }); + }); + + it("undoes completion when approver clicks Undo on a completed row", () => { + const orderedAt = "2026-05-01T00:00:00.000Z"; + const completedAt = "2026-05-06T10:00:00.000Z"; + stubGet([{ id: "11", measureName: "CWI", orderedAt, completedAt }]); + stubPatch("11"); + openDrawerAtPibiSection(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-complete-button-11]").should("contain.text", "Undo"); + cy.get("[data-testid=pibi-complete-button-11]").click(); + cy.wait("@patchPibiRequest-11").then((interception) => { + expect(interception.request.body.completedAt).to.be.null; + }); + }); + + // ── Delete ──────────────────────────────────────────────────────────────────── + + it("deletes a row when approver clicks Delete", () => { + const orderedAt = "2026-05-01T00:00:00.000Z"; + stubGet([{ id: "20", measureName: "EWI", orderedAt, completedAt: null }]); + stubDelete("20"); + openDrawerAtPibiSection(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-delete-button-20]").click(); + cy.wait("@deletePibiRequest-20"); + }); + + // ── Mark all complete ───────────────────────────────────────────────────────── + + it("marks all rows in a batch complete via the batch header button", () => { + const orderedAt = "2026-05-01T00:00:00.000Z"; + stubGet([ + { id: "30", measureName: "ASHP", orderedAt, completedAt: null }, + { id: "31", measureName: "CWI", orderedAt, completedAt: null }, + ]); + stubPatch("30"); + stubPatch("31"); + openDrawerAtPibiSection(); + cy.wait("@getPibiRequests"); + + cy.get("[data-testid=pibi-batch-complete-button]").click(); + cy.wait("@patchPibiRequest-30"); + cy.wait("@patchPibiRequest-31"); + }); +}); 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/db/schema/pibi_requests.ts b/src/app/db/schema/pibi_requests.ts new file mode 100644 index 0000000..892f371 --- /dev/null +++ b/src/app/db/schema/pibi_requests.ts @@ -0,0 +1,37 @@ +import { + bigserial, + text, + timestamp, + pgTable, + bigint, + index, +} from "drizzle-orm/pg-core"; +import { user } from "./users"; +import { portfolio } from "./portfolio"; + +export const pibiRequests = pgTable( + "pibi_requests", + { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + hubspotDealId: text("hubspot_deal_id").notNull(), + portfolioId: bigint("portfolio_id", { mode: "bigint" }) + .notNull() + .references(() => portfolio.id), + measureName: text("measure_name").notNull(), + orderedAt: timestamp("ordered_at", { withTimezone: true }) + .defaultNow() + .notNull(), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdByUserId: bigint("created_by_user_id", { mode: "bigint" }) + .notNull() + .references(() => user.id), + pushedAt: timestamp("pushed_at", { withTimezone: true }), + }, + (table) => [ + index("idx_pibi_requests_deal_id").on(table.hubspotDealId), + index("idx_pibi_requests_portfolio_id").on(table.portfolioId), + ], +); + +export type PibiRequest = typeof pibiRequests.$inferSelect; +export type NewPibiRequest = typeof pibiRequests.$inferInsert; 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/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/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)/your-projects/live/PibiSection.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx new file mode 100644 index 0000000..631bda4 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx @@ -0,0 +1,634 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; +import { + groupByBatch, + formatDate, + toDateInputValue, + dateInputToIso, +} from "@/app/lib/pibiSectionHelpers"; +import type { PibiRow, PibiBatch } from "@/app/lib/pibiSectionHelpers"; + +// ── Measure badge ───────────────────────────────────────────────────────────── + +function MeasureScopeBadge({ + measure, + approvedMeasures, + proposedMeasures, +}: { + measure: string; + approvedMeasures: string[]; + proposedMeasures: string[]; +}) { + if (approvedMeasures.includes(measure)) { + return ( + + Approved + + ); + } + if (proposedMeasures.includes(measure)) { + return ( + + Proposed + + ); + } + return null; +} + +// ── Inline row editor ───────────────────────────────────────────────────────── + +function PibiRowEditor({ + row, + portfolioId, + dealId, + onSaved, + onCancel, +}: { + row: PibiRow; + portfolioId: string; + dealId: string; + onSaved: () => void; + onCancel: () => void; +}) { + const [measureName, setMeasureName] = useState(row.measureName); + const [orderedAt, setOrderedAt] = useState(toDateInputValue(row.orderedAt)); + const [completedAt, setCompletedAt] = useState(toDateInputValue(row.completedAt)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function handleSave() { + setSaving(true); + setError(null); + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests/${row.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dealId, + measureName, + orderedAt: orderedAt ? dateInputToIso(orderedAt) : undefined, + completedAt: completedAt ? dateInputToIso(completedAt) : null, + }), + }, + ); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(typeof json.error === "string" ? json.error : "Save failed"); + } + onSaved(); + } catch (err) { + setError(err instanceof Error ? err.message : "Save failed"); + } finally { + setSaving(false); + } + } + + return ( + + + + + + setOrderedAt(e.target.value)} + className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40" + /> + + + setCompletedAt(e.target.value)} + className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40" + /> + + +
+ + +
+ {error && ( +

{error}

+ )} + + + ); +} + +// ── Batch group ─────────────────────────────────────────────────────────────── + +function PibiBatchGroup({ + batch, + portfolioId, + dealId, + approvedMeasures, + proposedMeasures, + canEdit, + onMutated, +}: { + batch: PibiBatch; + portfolioId: string; + dealId: string; + approvedMeasures: string[]; + proposedMeasures: string[]; + canEdit: boolean; + onMutated: () => void; +}) { + const [editingId, setEditingId] = useState(null); + const [completing, setCompleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + const allComplete = batch.rows.every((r) => r.completedAt !== null); + const completedAt = new Date().toISOString(); + + async function handleBatchComplete() { + setCompleting(true); + try { + await Promise.all( + batch.rows + .filter((r) => !r.completedAt) + .map((r) => + fetch(`/api/portfolio/${portfolioId}/pibi-requests/${r.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dealId, completedAt }), + }), + ), + ); + onMutated(); + } finally { + setCompleting(false); + } + } + + async function handleDelete(id: string) { + setDeleteError(null); + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests/${id}?dealId=${encodeURIComponent(dealId)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Delete failed"); + onMutated(); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Delete failed"); + } + } + + async function handleRowComplete(row: PibiRow) { + const newCompletedAt = row.completedAt ? null : completedAt; + await fetch(`/api/portfolio/${portfolioId}/pibi-requests/${row.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dealId, completedAt: newCompletedAt }), + }); + onMutated(); + } + + return ( +
+ {/* Batch header */} +
+ + Ordered {formatDate(batch.orderedAt)} + + {canEdit && !allComplete && ( + + )} + {allComplete && ( + + + All complete + + )} +
+ + {/* Rows */} + + + + + + + {canEdit && ( + + )} + + + + {batch.rows.map((row) => + editingId === row.id ? ( + { setEditingId(null); onMutated(); }} + onCancel={() => setEditingId(null)} + /> + ) : ( + + + + + {canEdit && ( + + )} + + ), + )} + +
+ Measure + + Ordered + + Completed + + Actions +
+
+ + {row.measureName} + + +
+
+ {formatDate(row.orderedAt)} + + {row.completedAt ? ( + + {formatDate(row.completedAt)} + + ) : ( + + )} + +
+ + + +
+
+ {deleteError && ( +

{deleteError}

+ )} +
+ ); +} + +// ── Log PIBI form ───────────────────────────────────────────────────────────── + +function LogPibiForm({ + portfolioId, + dealId, + approvedMeasures, + proposedMeasures, + onSuccess, + onCancel, +}: { + portfolioId: string; + dealId: string; + approvedMeasures: string[]; + proposedMeasures: string[]; + onSuccess: () => void; + onCancel: () => void; +}) { + const inScope = new Set([...approvedMeasures, ...proposedMeasures]); + const [checked, setChecked] = useState>(new Set()); + const [orderedAt, setOrderedAt] = useState(toDateInputValue(new Date().toISOString())); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + function toggle(measure: string) { + setChecked((prev) => { + const next = new Set(prev); + next.has(measure) ? next.delete(measure) : next.add(measure); + return next; + }); + } + + async function handleSubmit() { + if (checked.size === 0) { + setError("Select at least one measure"); + return; + } + setSubmitting(true); + setError(null); + try { + const res = await fetch(`/api/portfolio/${portfolioId}/pibi-requests`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dealId, + measureNames: Array.from(checked), + orderedAt: orderedAt ? new Date(`${orderedAt}T00:00:00.000Z`).toISOString() : undefined, + }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(typeof json.error === "string" ? json.error : "Failed to log PIBI"); + } + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to log PIBI"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

+ Log PIBI +

+ +
+ + {/* Date picker */} +
+ + setOrderedAt(e.target.value)} + data-testid="pibi-order-date-input" + className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40" + /> +
+ + {/* Measure checkboxes */} +
+ {/* In-scope measures first */} + {MEASURE_NAMES.filter((m) => inScope.has(m)).length > 0 && ( +

+ In scope +

+ )} + {MEASURE_NAMES.filter((m) => inScope.has(m)).map((measure) => ( + + ))} + + {/* Out-of-scope measures */} + {MEASURE_NAMES.filter((m) => !inScope.has(m)).length > 0 && ( +

+ Other measures +

+ )} + {MEASURE_NAMES.filter((m) => !inScope.has(m)).map((measure) => ( + + ))} +
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+ ); +} + +// ── Main section ────────────────────────────────────────────────────────────── + +interface PibiSectionProps { + dealId: string; + portfolioId: string; + proposedMeasures: string[]; + canEdit: boolean; +} + +export function PibiSection({ + dealId, + portfolioId, + proposedMeasures, + canEdit, +}: PibiSectionProps) { + const queryClient = useQueryClient(); + const [showForm, setShowForm] = useState(false); + + 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], + ); + + const batches = useMemo( + () => groupByBatch(data?.pibiRequests ?? []), + [data], + ); + + function invalidate() { + void queryClient.invalidateQueries({ queryKey: ["pibiRequests", portfolioId, dealId] }); + } + + if (isLoading) { + return ( +
+ Loading PIBIs… +
+ ); + } + + return ( +
+ {/* Header row */} +
+ PIBI Requests + {canEdit && !showForm && ( + + )} +
+ + {/* Log form */} + {showForm && ( + { setShowForm(false); invalidate(); }} + onCancel={() => setShowForm(false)} + /> + )} + + {/* Empty state */} + {batches.length === 0 && !showForm && ( +
+

No PIBIs logged yet

+ {canEdit && ( + + )} +
+ )} + + {/* PIBI batch table(s) */} + {batches.map((batch) => ( + + ))} +
+ ); +} 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 4aa2b46..4f90bb7 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -30,6 +30,7 @@ import { ApprovalConfirmDialog } from "./ApprovalConfirmDialog"; import type { PendingDiff } from "./ApprovalConfirmDialog"; import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings"; +import { PibiSection } from "./PibiSection"; import { useToast } from "@/app/hooks/use-toast"; // Sections the caller can request focus on. Used by entry-points like the @@ -1642,7 +1643,6 @@ export default function PropertyDetailDrawer({ }, [focusSection, deal?.dealId]); // Parsed measure lists. - const pibiMeasures = parseMeasures(deal?.measuresForPibiOrdered ?? null); const technicalApprovedMeasures = parseMeasures( deal?.technicalApprovedMeasuresForInstall ?? null, ); @@ -1871,43 +1871,12 @@ export default function PropertyDetailDrawer({ {/* PIBI */}
-
- - {userCapability.includes("approver") ? ( - - ) : ( - pibiMeasures.length > 0 && ( -
- - {pibiMeasures.map((m) => ( - - {m} - - ))} - - } - /> -
- ) - )} -
+
{/* Survey request */}