diff --git a/.github/workflows/nextjs-build.yml b/.github/workflows/nextjs-build.yml index d87d0c9..adc233e 100644 --- a/.github/workflows/nextjs-build.yml +++ b/.github/workflows/nextjs-build.yml @@ -1,11 +1,12 @@ -name: Next.js Build Check +name: Test Suite on: push: branches: - - "**" # all branches + - "**" + jobs: - build: + unit-tests: runs-on: ubuntu-latest steps: @@ -21,5 +22,5 @@ jobs: - name: Install dependencies run: npm ci - - name: Build Next.js app - run: npm run build + - name: Run unit tests + run: npm test diff --git a/cypress/e2e/live-tracking/domna-survey.cy.js b/cypress/e2e/live-tracking/domna-survey.cy.js index cf5ec53..554fa8c 100644 --- a/cypress/e2e/live-tracking/domna-survey.cy.js +++ b/cypress/e2e/live-tracking/domna-survey.cy.js @@ -45,6 +45,8 @@ describe("Domna survey editor — approver flow", function () { } cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + // Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click). + cy.get("[data-testid=drawer-tab-survey-admin]").click(); cy.get("[data-testid=drawer-section-domna]").should("exist"); } diff --git a/cypress/e2e/live-tracking/halted-state.cy.js b/cypress/e2e/live-tracking/halted-state.cy.js index 8249ebe..89066d2 100644 --- a/cypress/e2e/live-tracking/halted-state.cy.js +++ b/cypress/e2e/live-tracking/halted-state.cy.js @@ -43,6 +43,8 @@ describe("Halted state editor — approver flow", function () { } cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + // Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click). + cy.get("[data-testid=drawer-tab-survey-admin]").click(); cy.get("[data-testid=drawer-section-halted]").should("exist"); } diff --git a/cypress/e2e/live-tracking/measure-approval-drawer.cy.js b/cypress/e2e/live-tracking/measure-approval-drawer.cy.js new file mode 100644 index 0000000..0e25867 --- /dev/null +++ b/cypress/e2e/live-tracking/measure-approval-drawer.cy.js @@ -0,0 +1,172 @@ +/** + * Live Tracking — Measure approval drawer (Works tab) + * + * Verifies the approver flow for approving/unapproving proposed measures + * directly from the Works tab of the PropertyDetailDrawer: + * 1. The approver opens the Works tab and sees measure chips. + * 2. The approver toggles a measure, clicks "Review & Save". + * 3. The ApprovalConfirmDialog appears — the user types "approve". + * 4. POST fires to /api/portfolio/*/approvals with the correct payload. + * + * Mirrors the same structure as `pibi-measures.cy.js`. + * Uses `cy.intercept` to observe the API call without a real CRM round-trip. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); +const TARGET_DEAL_NAME = Cypress.env("LIVE_APPROVAL_DEAL_NAME"); + +describe("Measure approval drawer — Works tab approver flow", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log( + "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", + ); + this.skip(); + } + }); + + function openDrawerAtWorksTab() { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + + // Open a property row from the Measures table 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"); + + // The drawer opens on the Works tab from a Measures row click — verify. + cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible"); + cy.get("[data-testid=drawer-section-measures]").should("exist"); + } + + it("fetches approved measures and shows chips in the Works tab for approvers", () => { + // Stub the GET so we control the initial state. + cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, { + body: { + pibiMeasures: [], + approvedMeasures: ["ASHP", "Solar PV"], + instructedMeasures: [], + }, + }).as("getMeasures"); + + openDrawerAtWorksTab(); + + cy.wait("@getMeasures"); + + // Chip container should be visible. + cy.get("[data-testid=measure-approval-chips]").should("be.visible"); + + // ASHP and Solar PV should be shown as approved (checked). + cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked"); + cy.get("[data-testid='measure-approval-checkbox-Solar PV']").should( + "be.checked", + ); + }); + + it("lets an approver toggle a measure and POST the approval change", () => { + // Stub GET to return ASHP as already approved. + cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, { + body: { + pibiMeasures: [], + approvedMeasures: ["ASHP"], + instructedMeasures: [], + }, + }).as("getMeasures"); + + // Intercept the POST so we can assert the payload. + cy.intercept("POST", `/api/portfolio/*/approvals`, { + body: { success: true }, + }).as("postApprovals"); + + openDrawerAtWorksTab(); + + cy.wait("@getMeasures"); + + cy.get("[data-testid=measure-approval-chips]").should("be.visible"); + + // ASHP should start approved. + cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked"); + + // Toggle ASHP off (unapprove it). + cy.get("[data-testid=measure-approval-chip-ASHP]").click(); + cy.get("[data-testid=measure-approval-checkbox-ASHP]").should( + "not.be.checked", + ); + + // "Review & Save" button should now be active. + cy.get("[data-testid=measure-approval-save]").should("not.be.disabled"); + cy.get("[data-testid=measure-approval-save]").click(); + + // ApprovalConfirmDialog should be visible — type the confirm word. + cy.contains("Confirm approval changes").should("be.visible"); + cy.get("input[placeholder*=\"approve\"]").type("approve"); + cy.contains("button", "Confirm").click(); + + // Wait for the POST and assert the payload. + cy.wait("@postApprovals").then((intercepted) => { + expect(intercepted.request.body).to.have.property("changes"); + const changes = intercepted.request.body.changes; + // Should contain one change: ASHP unapproved. + const ashpChange = changes.find((c) => c.measureName === "ASHP"); + expect(ashpChange).to.exist; + expect(ashpChange.approved).to.equal(false); + }); + + // No error banner visible. + cy.get("[data-testid=measure-approval-error]").should("not.exist"); + }); + + it("lets an approver approve a new measure and POST with correct payload", () => { + // Stub GET — no measures approved yet. + cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, { + body: { + pibiMeasures: [], + approvedMeasures: [], + instructedMeasures: [], + }, + }).as("getMeasures"); + + // Intercept POST. + cy.intercept("POST", `/api/portfolio/*/approvals`, { + body: { success: true }, + }).as("postApprovals"); + + openDrawerAtWorksTab(); + + cy.wait("@getMeasures"); + + cy.get("[data-testid=measure-approval-chips]").should("be.visible"); + + // Click the first chip to approve it. + cy.get("[data-testid=measure-approval-chips]") + .find("label") + .first() + .click(); + + // Save button should be active. + cy.get("[data-testid=measure-approval-save]").should("not.be.disabled"); + cy.get("[data-testid=measure-approval-save]").click(); + + // Confirm dialog — type the word. + cy.contains("Confirm approval changes").should("be.visible"); + cy.get("input[placeholder*=\"approve\"]").type("approve"); + cy.contains("button", "Confirm").click(); + + cy.wait("@postApprovals").then((intercepted) => { + expect(intercepted.request.body).to.have.property("changes"); + const changes = intercepted.request.body.changes; + // Should have at least one approval. + expect(changes.length).to.be.greaterThan(0); + expect(changes[0]).to.have.property("approved", true); + expect(changes[0]).to.have.property("hubspotDealId"); + }); + + // No error banner. + cy.get("[data-testid=measure-approval-error]").should("not.exist"); + }); +}); diff --git a/cypress/e2e/live-tracking/pibi-dates.cy.js b/cypress/e2e/live-tracking/pibi-dates.cy.js index 84098ab..d5cc717 100644 --- a/cypress/e2e/live-tracking/pibi-dates.cy.js +++ b/cypress/e2e/live-tracking/pibi-dates.cy.js @@ -41,6 +41,8 @@ describe("PIBI dates editor — write user flow", function () { } 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"); } diff --git a/cypress/e2e/live-tracking/pibi-measures.cy.js b/cypress/e2e/live-tracking/pibi-measures.cy.js index 59afbe2..95ef81e 100644 --- a/cypress/e2e/live-tracking/pibi-measures.cy.js +++ b/cypress/e2e/live-tracking/pibi-measures.cy.js @@ -40,8 +40,9 @@ describe("PIBI measure selection — approver flow", function () { cy.get("[data-testid=property-detail-drawer]").should("be.visible"); - // Scroll to the PIBI section. - cy.get("[data-testid=drawer-section-pibi]").should("exist").scrollIntoView(); + // 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", () => { diff --git a/cypress/e2e/live-tracking/property-deal-page.cy.js b/cypress/e2e/live-tracking/property-deal-page.cy.js new file mode 100644 index 0000000..caef0e8 --- /dev/null +++ b/cypress/e2e/live-tracking/property-deal-page.cy.js @@ -0,0 +1,50 @@ +/** + * Live Tracking — Property Deal Page (replaces property-detail-drawer) + * + * Verifies the two core navigation behaviors after moving from a right-side + * drawer to a dedicated CRM-style deal page at /live/[dealId]: + * + * 1. Property table rows link to the correct deal page URL. + * 2. The deal page loads with the Works tab active by default. + * + * Reads LIVE_PORTFOLIO_SLUG from Cypress env so it runs against any seeded + * environment without hard-coding an ID. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); + +describe("Property deal page", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log( + "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", + ); + this.skip(); + } + }); + + it("property table row has link to deal page URL", () => { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + cy.contains("button, [role=tab]", "Properties").click(); + cy.get("[data-testid=property-row-link]") + .first() + .should("have.attr", "href") + .and( + "match", + new RegExp( + `/portfolio/${PORTFOLIO_SLUG}/your-projects/live/[^/]+$`, + ), + ); + }); + + it("deal page shows Works tab active by default", () => { + 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-works]").should( + "have.attr", + "aria-selected", + "true", + ); + }); +}); diff --git a/cypress/e2e/live-tracking/property-detail-drawer.cy.js b/cypress/e2e/live-tracking/property-detail-drawer.cy.js deleted file mode 100644 index 4e37908..0000000 --- a/cypress/e2e/live-tracking/property-detail-drawer.cy.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Live Tracking — Property Detail Drawer (issue #251) - * - * Verifies the row-click flow on the Measures tab: clicking a row in the - * MeasuresTable opens the PropertyDetailDrawer and all six stage-ordered - * sections are visible in the body. - * - * The spec assumes an authenticated session can be reused (or skipped) the - * same way the rest of the suite handles it. Because live tracking is a - * portfolio-scoped page, the test reads the target portfolio slug from the - * `LIVE_PORTFOLIO_SLUG` Cypress env var so it can run against any seeded - * environment without hard-coding an ID. - */ - -const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); - -const EXPECTED_SECTIONS = [ - "survey", - "measures", - "pibi", - "domna", - "halted", - "technical", -]; - -describe("Property detail drawer — measures row click", function () { - before(function () { - if (!PORTFOLIO_SLUG) { - // Skip the suite entirely when the env var is absent. The spec still - // compiles so CI pipelines that lint Cypress files stay green. - cy.log( - "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", - ); - this.skip(); - } - }); - - it("opens the drawer focused on Measures and shows all six sections", () => { - cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); - - // Switch to the Measures tab. - cy.contains("button, [role=tab]", "Measures").click(); - - // Click the first measures row. - cy.get("[data-testid=measures-row]").first().click(); - - // Drawer is open. - cy.get("[data-testid=property-detail-drawer]").should("be.visible"); - - // All six sections rendered inside the drawer. - EXPECTED_SECTIONS.forEach((section) => { - cy.get(`[data-testid=drawer-section-${section}]`).should("exist"); - }); - }); -}); diff --git a/cypress/e2e/live-tracking/survey-request.cy.js b/cypress/e2e/live-tracking/survey-request.cy.js new file mode 100644 index 0000000..5c15a74 --- /dev/null +++ b/cypress/e2e/live-tracking/survey-request.cy.js @@ -0,0 +1,83 @@ +/** + * Live Tracking — Survey request flow + * + * Verifies the client-facing "Request Survey" flow in the Survey & Admin tab: + * 1. User opens a property and navigates to Survey & Admin tab. + * 2. User fills in a free-text reason and submits the survey request. + * 3. The POST hits /api/portfolio/[id]/survey-requests. + * 4. The drawer reflects the pending request (badge shown). + * 5. On reload the pending request is still visible. + * + * Uses cy.intercept so the HubSpot side-effect is observable without a live + * CRM round-trip. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); +const TARGET_DEAL_NAME = Cypress.env("LIVE_SURVEY_REQUEST_DEAL_NAME"); + +const SURVEY_NOTES = "Please arrange a full retrofit assessment — tenant has moved in."; + +describe("Survey request — 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 openDrawerAtSurveyAdmin() { + 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-survey-admin]").click(); + cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible"); + } + + it("shows the survey request form in the Survey & Admin tab", () => { + openDrawerAtSurveyAdmin(); + + cy.get("[data-testid=survey-request-form]").should("be.visible"); + cy.get("[data-testid=survey-request-notes]").should("be.visible"); + cy.get("[data-testid=survey-request-submit]").should("be.visible"); + }); + + it("submits a survey request and shows a pending badge", () => { + cy.intercept( + "POST", + `/api/portfolio/*/survey-requests`, + ).as("createSurveyRequest"); + + openDrawerAtSurveyAdmin(); + + cy.get("[data-testid=survey-request-notes]").type(SURVEY_NOTES); + cy.get("[data-testid=survey-request-submit]") + .should("not.be.disabled") + .click(); + + cy.wait("@createSurveyRequest").then((intercepted) => { + expect(intercepted.request.body).to.have.property("notes"); + expect(intercepted.response.statusCode).to.be.oneOf([200, 201]); + expect(intercepted.response.body).to.have.property("ok", true); + }); + + // Pending badge appears after submission. + cy.get("[data-testid=survey-request-pending-badge]").should("be.visible"); + }); + + it("persists the pending request across a page reload", () => { + openDrawerAtSurveyAdmin(); + + // If a pending request exists it should be visible without submitting again. + cy.get("[data-testid=survey-request-pending-badge]").should("be.visible"); + }); +}); diff --git a/cypress/e2e/live-tracking/tabbed-drawer.cy.js b/cypress/e2e/live-tracking/tabbed-drawer.cy.js new file mode 100644 index 0000000..e13eacb --- /dev/null +++ b/cypress/e2e/live-tracking/tabbed-drawer.cy.js @@ -0,0 +1,91 @@ +/** + * Live Tracking — Tabbed property detail drawer + * + * Verifies the four-tab drawer structure introduced in the UI redesign: + * Overview | Works | PIBI | Survey & Admin + * + * The spec opens the drawer from the Properties table (first row) and asserts + * tab presence, default active state, and navigation between tabs. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); + +describe("Property detail drawer — tabbed layout", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log( + "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", + ); + this.skip(); + } + }); + + function openDrawer() { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + cy.contains("button, [role=tab]", "Measures").click(); + cy.get("[data-testid=measures-row]").first().click(); + cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + } + + it("opens with Overview tab active by default", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-overview]") + .should("be.visible") + .and("have.attr", "aria-selected", "true"); + + cy.get("[data-testid=drawer-tab-panel-overview]").should("be.visible"); + }); + + it("shows all four tabs", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-overview]").should("be.visible"); + cy.get("[data-testid=drawer-tab-works]").should("be.visible"); + cy.get("[data-testid=drawer-tab-pibi]").should("be.visible"); + cy.get("[data-testid=drawer-tab-survey-admin]").should("be.visible"); + }); + + it("navigates to Works tab and shows measures content", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-works]").click(); + + cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible"); + // Measures section lives in Works tab + cy.get("[data-testid=drawer-section-measures]").should("exist"); + }); + + it("navigates to PIBI tab and shows PIBI content", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-pibi]").click(); + + cy.get("[data-testid=drawer-tab-panel-pibi]").should("be.visible"); + cy.get("[data-testid=drawer-section-pibi]").should("exist"); + }); + + it("navigates to Survey & Admin tab and shows admin content", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-survey-admin]").click(); + + cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible"); + cy.get("[data-testid=drawer-section-domna]").should("exist"); + }); + + it("focusSection=pibi opens PIBI tab directly", () => { + // This is exercised by the pibi-dates.cy.js helper that clicks the Measures + // tab row — after the redesign those rows pass focusSection="pibi" and the + // drawer should land on the PIBI tab, not Overview. + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + + cy.contains("button, [role=tab]", "Measures").click(); + cy.get("[data-testid=measures-row]").first().click(); + + cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + + cy.get("[data-testid=drawer-tab-works]") + .should("have.attr", "aria-selected", "true"); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts b/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts index eb5830d..ff7a74d 100644 --- a/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts +++ b/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts @@ -133,7 +133,6 @@ export async function POST( return NextResponse.json({ ok: true, hubspotSync: result.hubspotSync, - autoPopulatedProposed: result.autoPopulatedProposed, hubspotError: result.hubspotError, }); } catch (err) { diff --git a/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts new file mode 100644 index 0000000..8190fa9 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts @@ -0,0 +1,167 @@ +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 { user } from "@/app/db/schema/users"; +import { and, eq, desc } from "drizzle-orm"; +import { z } from "zod"; +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 }) + .from(user) + .where(eq(user.email, email)) + .limit(1); + return rows[0] ?? null; +} + +async function getUserRole(portfolioId: bigint, userId: bigint) { + const rows = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, portfolioId), + eq(portfolioUsers.userId, userId), + ), + ) + .limit(1); + return rows[0]?.role ?? 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 }> }, +) { + const { portfolioId } = await props.params; + const dealId = req.nextUrl.searchParams.get("dealId"); + + if (!dealId) { + return NextResponse.json({ error: "dealId required" }, { status: 400 }); + } + + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + try { + const rows = await db + .select({ + id: surveyRequests.id, + hubspotDealId: surveyRequests.hubspotDealId, + notes: surveyRequests.notes, + status: surveyRequests.status, + requestedAt: surveyRequests.requestedAt, + fulfilledAt: surveyRequests.fulfilledAt, + requestedByEmail: user.email, + }) + .from(surveyRequests) + .innerJoin(user, eq(user.id, surveyRequests.requestedBy)) + .where( + and( + eq(surveyRequests.hubspotDealId, dealId), + eq(surveyRequests.portfolioId, BigInt(portfolioId)), + ), + ) + .orderBy(desc(surveyRequests.requestedAt)) + .limit(20); + + const requests = rows.map((r) => ({ + id: String(r.id), + hubspotDealId: r.hubspotDealId, + notes: r.notes, + status: r.status, + requestedByEmail: r.requestedByEmail, + requestedAt: r.requestedAt?.toISOString() ?? null, + fulfilledAt: r.fulfilledAt?.toISOString() ?? null, + })); + + return NextResponse.json({ requests }); + } catch (err) { + console.error("[survey-requests GET]", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +const postSchema = z.object({ + hubspotDealId: z.string().min(1), + notes: z.string().min(1, "Notes are required"), +}); + +// POST /api/portfolio/[portfolioId]/survey-requests +// Submit a new survey request — requires write+ role. +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 }); + } + + const requestingUser = await getRequestingUser(session.user.email); + if (!requestingUser) { + 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])) { + return NextResponse.json( + { error: "Write access required to submit a survey request" }, + { status: 403 }, + ); + } + + 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 { hubspotDealId, notes } = parsed.data; + + try { + const [inserted] = await db + .insert(surveyRequests) + .values({ + hubspotDealId, + portfolioId: BigInt(portfolioId), + notes, + status: "pending", + requestedBy: requestingUser.id, + }) + .returning({ id: surveyRequests.id }); + + const hubspotResult = await syncSurveyRequestToHubSpot({ + hubspotDealId, + notes, + requestedByEmail: requestingUser.email, + }); + + return NextResponse.json({ + ok: true, + id: String(inserted.id), + hubspotSync: hubspotResult.ok ? "ok" : "failed", + hubspotError: hubspotResult.error, + }); + } catch (err) { + console.error("[survey-requests POST]", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/db/migrations/0193_domna_survey_type.sql b/src/app/db/migrations/0193_domna_survey_type.sql index d2604eb..e410bf2 100644 --- a/src/app/db/migrations/0193_domna_survey_type.sql +++ b/src/app/db/migrations/0193_domna_survey_type.sql @@ -1,2 +1 @@ -ALTER TABLE "hubspot_deal_data" DROP COLUMN IF EXISTS "domna_survey_required";--> statement-breakpoint ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_type" text; diff --git a/src/app/db/migrations/0195_survey_requests.sql b/src/app/db/migrations/0195_survey_requests.sql new file mode 100644 index 0000000..c7f197b --- /dev/null +++ b/src/app/db/migrations/0195_survey_requests.sql @@ -0,0 +1,15 @@ +CREATE TABLE "survey_requests" ( + "id" bigserial PRIMARY KEY NOT NULL, + "hubspot_deal_id" text NOT NULL, + "portfolio_id" bigint NOT NULL, + "notes" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "requested_by" bigint NOT NULL, + "requested_at" timestamp with time zone DEFAULT now() NOT NULL, + "fulfilled_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_survey_requests_deal_id" ON "survey_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint +CREATE INDEX "idx_survey_requests_portfolio_id" ON "survey_requests" USING btree ("portfolio_id"); diff --git a/src/app/db/schema/crm/hubspot_deal_table.ts b/src/app/db/schema/crm/hubspot_deal_table.ts index 7bdf64f..4498dfd 100644 --- a/src/app/db/schema/crm/hubspot_deal_table.ts +++ b/src/app/db/schema/crm/hubspot_deal_table.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, boolean } from "drizzle-orm/pg-core"; import { InferModel } from "drizzle-orm"; export const hubspotDealData = pgTable("hubspot_deal_data", { @@ -66,6 +66,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", { propertyHaltedReason: text("property_halted_reason"), technicalApprovedMeasuresForInstall: text("technical_approved_measures_for_install"), sentToInstallerForPricing: timestamp("sent_to_installer_for_pricing", { precision: 6, withTimezone: true }), + domnasurveyRequired: boolean("domna_survey_required"), domnaSurveyType: text("domna_survey_type"), domnaSurveyDate: timestamp("domna_survey_date", { precision: 6, withTimezone: true }), diff --git a/src/app/db/schema/survey_requests.ts b/src/app/db/schema/survey_requests.ts new file mode 100644 index 0000000..815d18f --- /dev/null +++ b/src/app/db/schema/survey_requests.ts @@ -0,0 +1,41 @@ +import { + bigserial, + text, + timestamp, + pgTable, + bigint, + index, +} from "drizzle-orm/pg-core"; +import { user } from "./users"; +import { portfolio } from "./portfolio"; + +// One row per survey request from a portfolio user. A deal can accumulate +// multiple requests over time; query by hubspotDealId ordered by requestedAt +// desc to get the latest. +export const surveyRequests = pgTable( + "survey_requests", + { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + hubspotDealId: text("hubspot_deal_id").notNull(), + 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(), + // 'pending' | 'fulfilled' + status: text("status").notNull().default("pending"), + requestedBy: bigint("requested_by", { mode: "bigint" }) + .notNull() + .references(() => user.id), + requestedAt: timestamp("requested_at", { withTimezone: true }) + .defaultNow() + .notNull(), + fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }), + }, + (table) => [ + index("idx_survey_requests_deal_id").on(table.hubspotDealId), + index("idx_survey_requests_portfolio_id").on(table.portfolioId), + ], +); + +export type SurveyRequest = typeof surveyRequests.$inferSelect; diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts index 05662ad..8b08c89 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -185,6 +185,27 @@ export async function syncMeasuresFieldToHubSpot(params: { return { ok: false, error: "HubSpot sync failed" }; } +export async function syncSurveyRequestToHubSpot(params: { + hubspotDealId: string; + notes: string; + requestedByEmail: string; +}): 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 }, + }); + return { ok: true }; + } catch (err) { + console.error("[HubSpot] syncSurveyRequestToHubSpot failed", { + dealId: params.hubspotDealId, + error: err, + }); + return { ok: false, error: "HubSpot sync failed" }; + } +} + export async function syncMeasureApprovalsToHubSpot(params: { hubspotDealId: string; approvedMeasures: Array<{ measureName: string; approvedByEmail: string }>; diff --git a/src/app/lib/instructMeasure.test.ts b/src/app/lib/instructMeasure.test.ts index 80089c7..7f8ae71 100644 --- a/src/app/lib/instructMeasure.test.ts +++ b/src/app/lib/instructMeasure.test.ts @@ -2,14 +2,14 @@ * Unit tests for the instruct-measure service. * * These tests exercise the orchestration logic — DB transaction + - * post-commit HubSpot push + auto-populate-proposed branch — by injecting - * lightweight fakes for the DB hooks. The tests never touch the real DB - * or HubSpot client. + * post-commit HubSpot push — by injecting lightweight fakes for the DB hooks. + * The tests never touch the real DB or HubSpot client. */ import { describe, expect, it, vi } from "vitest"; import { INSTRUCTED_MEASURES_PROP, PROPOSED_MEASURES_PROP, + APPROVED_MEASURES_PROP, instructMeasure, } from "./instructMeasure"; import type { @@ -31,8 +31,8 @@ function makeDeps(overrides?: { }) { const txOutcome: InstructTxOutcome = { instructedRowId: 42n, - proposedWasEmpty: false, - hadNoApprovals: false, + existingProposedMeasures: [], + allApprovedMeasureNames: [], ...overrides?.txOutcome, }; const runInstructTx: RunInstructTx = vi.fn(async () => { @@ -43,7 +43,7 @@ function makeDeps(overrides?: { async () => overrides?.instructedAfter ?? ["ASHP"], ); const syncQueue: Array<{ ok: true } | { ok: false; error: string }> = - overrides?.syncResults ?? [{ ok: true }]; + overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }]; const syncMeasuresField: SyncMeasuresField = vi.fn(async () => { return syncQueue.shift() ?? ({ ok: true } as const); }); @@ -90,13 +90,13 @@ describe("instructMeasure — input validation", () => { }); describe("instructMeasure — happy path", () => { - it("commits the tx, pushes the full instructed list, stamps pushed_at", async () => { + it("commits tx, pushes instructed + proposed + approved, stamps pushed_at", async () => { const deps = makeDeps({ instructedAfter: ["ASHP", "Solar PV"], txOutcome: { instructedRowId: 99n, - proposedWasEmpty: false, - hadNoApprovals: false, + existingProposedMeasures: ["ASHP"], + allApprovedMeasureNames: ["ASHP", "Solar PV"], }, }); const result = await instructMeasure({ @@ -109,7 +109,6 @@ describe("instructMeasure — happy path", () => { ok: true, instructedRowId: 99n, hubspotSync: "ok", - autoPopulatedProposed: false, }); expect(deps.runInstructTx).toHaveBeenCalledWith({ dealId: "deal-42", @@ -117,80 +116,54 @@ describe("instructMeasure — happy path", () => { userId: 7n, notes: null, }); - expect(deps.syncMeasuresField).toHaveBeenCalledTimes(1); - expect(deps.syncMeasuresField).toHaveBeenCalledWith({ + 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).toHaveBeenCalledWith(99n); }); -}); -describe("instructMeasure — auto-populate proposed branch", () => { - it("also pushes proposed_measures when proposed was empty AND no prior approvals", async () => { + it("merges new measure into existing proposed (deduped)", async () => { const deps = makeDeps({ - instructedAfter: ["EWI"], + instructedAfter: ["ASHP", "EWI"], txOutcome: { instructedRowId: 1n, - proposedWasEmpty: true, - hadNoApprovals: true, + existingProposedMeasures: ["ASHP", "Solar PV"], + allApprovedMeasureNames: ["ASHP", "EWI"], }, }); - const result = await instructMeasure({ - dealId: "deal-blank", + await instructMeasure({ + dealId: "deal-merge", measureName: "EWI", userId: 3n, deps, }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.autoPopulatedProposed).toBe(true); - expect(result.hubspotSync).toBe("ok"); - } - expect(deps.syncMeasuresField).toHaveBeenCalledTimes(2); - expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, { - hubspotDealId: "deal-blank", - propName: INSTRUCTED_MEASURES_PROP, - measureNames: ["EWI"], - }); expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, { - hubspotDealId: "deal-blank", + hubspotDealId: "deal-merge", propName: PROPOSED_MEASURES_PROP, - measureNames: ["EWI"], + measureNames: ["ASHP", "Solar PV", "EWI"], }); }); - it("does NOT auto-populate proposed when proposed was empty but approvals already exist", async () => { + it("adds to proposed even when deal already had proposed measures", async () => { const deps = makeDeps({ instructedAfter: ["EWI"], txOutcome: { instructedRowId: 1n, - proposedWasEmpty: true, - hadNoApprovals: false, // an approval already existed - }, - }); - const result = await instructMeasure({ - dealId: "deal-mid-flow", - measureName: "EWI", - userId: 3n, - deps, - }); - expect(result.ok).toBe(true); - if (result.ok) expect(result.autoPopulatedProposed).toBe(false); - expect(deps.syncMeasuresField).toHaveBeenCalledTimes(1); - expect(deps.syncMeasuresField).toHaveBeenCalledWith( - expect.objectContaining({ propName: INSTRUCTED_MEASURES_PROP }), - ); - }); - - it("does NOT auto-populate proposed when proposed already has measures", async () => { - const deps = makeDeps({ - instructedAfter: ["EWI"], - txOutcome: { - instructedRowId: 1n, - proposedWasEmpty: false, - hadNoApprovals: true, + existingProposedMeasures: ["ASHP", "Solar PV"], + allApprovedMeasureNames: ["EWI"], }, }); const result = await instructMeasure({ @@ -200,8 +173,35 @@ describe("instructMeasure — auto-populate proposed branch", () => { deps, }); expect(result.ok).toBe(true); - if (result.ok) expect(result.autoPopulatedProposed).toBe(false); - expect(deps.syncMeasuresField).toHaveBeenCalledTimes(1); + expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, + expect.objectContaining({ + propName: PROPOSED_MEASURES_PROP, + measureNames: ["ASHP", "Solar PV", "EWI"], + }), + ); + }); + + it("adds to proposed when deal has no existing proposed measures", async () => { + const deps = makeDeps({ + instructedAfter: ["EWI"], + txOutcome: { + instructedRowId: 1n, + existingProposedMeasures: [], + allApprovedMeasureNames: ["EWI"], + }, + }); + await instructMeasure({ + dealId: "deal-blank", + measureName: "EWI", + userId: 3n, + deps, + }); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, { + hubspotDealId: "deal-blank", + propName: PROPOSED_MEASURES_PROP, + measureNames: ["EWI"], + }); }); }); @@ -222,15 +222,19 @@ describe("instructMeasure — DB transaction failure", () => { }); describe("instructMeasure — HubSpot push failure leaves DB committed", () => { - it("returns ok=true with hubspotSync=failed and does NOT stamp pushed_at", async () => { + it("returns ok=true with hubspotSync=failed when instructed push fails, does NOT stamp", async () => { const deps = makeDeps({ instructedAfter: ["ASHP"], txOutcome: { instructedRowId: 11n, - proposedWasEmpty: false, - hadNoApprovals: false, + existingProposedMeasures: [], + allApprovedMeasureNames: ["ASHP"], }, - syncResults: [{ ok: false, error: "hubspot 500" }], + syncResults: [ + { ok: false, error: "hubspot 500" }, + { ok: true }, + { ok: true }, + ], }); const result = await instructMeasure({ dealId: "deal-h", @@ -244,21 +248,21 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => { hubspotSync: "failed", hubspotError: "hubspot 500", }); - expect(deps.runInstructTx).toHaveBeenCalledTimes(1); expect(deps.stampPushedAt).not.toHaveBeenCalled(); }); - it("treats a failed proposed-measures push as overall failure (still no stamp)", async () => { + it("returns hubspotSync=failed when proposed push fails", async () => { const deps = makeDeps({ instructedAfter: ["EWI"], txOutcome: { instructedRowId: 12n, - proposedWasEmpty: true, - hadNoApprovals: true, + existingProposedMeasures: [], + allApprovedMeasureNames: ["EWI"], }, syncResults: [ { ok: true }, { ok: false, error: "proposed push failed" }, + { ok: true }, ], }); const result = await instructMeasure({ @@ -271,7 +275,34 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => { ok: true, hubspotSync: "failed", hubspotError: "proposed push failed", - autoPopulatedProposed: true, + }); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); + + it("returns hubspotSync=failed when approved push fails", async () => { + const deps = makeDeps({ + instructedAfter: ["EWI"], + txOutcome: { + instructedRowId: 13n, + existingProposedMeasures: [], + allApprovedMeasureNames: ["EWI"], + }, + syncResults: [ + { ok: true }, + { ok: true }, + { ok: false, error: "approved push failed" }, + ], + }); + const result = await instructMeasure({ + dealId: "deal-blank", + measureName: "EWI", + userId: 3n, + deps, + }); + expect(result).toMatchObject({ + ok: true, + hubspotSync: "failed", + hubspotError: "approved push failed", }); expect(deps.stampPushedAt).not.toHaveBeenCalled(); }); diff --git a/src/app/lib/instructMeasure.ts b/src/app/lib/instructMeasure.ts index d59bb46..a7421ca 100644 --- a/src/app/lib/instructMeasure.ts +++ b/src/app/lib/instructMeasure.ts @@ -10,18 +10,19 @@ * 1. insert a `user_defined_deal_measures` row with `source = "instructed"` * 2. insert a `deal_measure_approvals` row with `is_approved = true` * 3. insert a corresponding `deal_measure_approval_events` row - * 4. read the deal's current `proposed_measures` + total approval count + * 4. read the deal's current `proposed_measures` list + * 5. read all currently approved measure names for the deal * - * After the transaction commits, push the instructed-measures list to - * HubSpot under `instructed_measures`. If `proposed_measures` was empty - * AND the deal had no prior approvals, ALSO push the new measure as - * `proposed_measures` so the deal has a coherent measures starting point. + * After the transaction commits, push three HubSpot fields: + * - `instructed_measures`: full list of all instructed measures (audit trail) + * - `proposed_measures`: existing list merged with the new measure (always) + * - `approved_measures`: all currently approved measures for the deal * * HubSpot push failures DO NOT roll back the DB. Successful pushes stamp * `pushed_at` on the new local row; failures leave it null so a follow-up * reconcile job can retry. */ -import { and, eq, sql } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db } from "@/app/db/db"; import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; import { @@ -38,12 +39,15 @@ import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/li export const INSTRUCTED_MEASURES_PROP = "instructed_measures"; export const PROPOSED_MEASURES_PROP = "proposed_measures"; +export const APPROVED_MEASURES_PROP = "approved_measures"; /** Snapshot returned by the transactional step of the service. */ export interface InstructTxOutcome { instructedRowId: bigint; - proposedWasEmpty: boolean; - hadNoApprovals: boolean; + /** Existing proposed measures from hubspot_deal_data before this instruction. */ + existingProposedMeasures: string[]; + /** All approved measure names for the deal after this instruction's approval row is upserted. */ + allApprovedMeasureNames: string[]; } export type SyncMeasuresField = typeof defaultSyncMeasuresField; @@ -66,7 +70,6 @@ export type InstructMeasureResult = ok: true; instructedRowId: bigint; hubspotSync: "ok" | "failed"; - autoPopulatedProposed: boolean; hubspotError?: string; } | { ok: false; error: string }; @@ -154,19 +157,20 @@ const defaultRunInstructTx: RunInstructTx = async ({ .from(hubspotDealData) .where(eq(hubspotDealData.dealId, dealId)) .limit(1); - const proposedRaw = dealRows[0]?.proposedMeasures ?? null; - const proposedWasEmpty = parseMeasures(proposedRaw).length === 0; + const existingProposedMeasures = parseMeasures(dealRows[0]?.proposedMeasures ?? null); - const approvalCountRows = await tx - .select({ count: sql`count(*)::int` }) + const approvedRows = await tx + .select({ measureName: dealMeasureApprovals.measureName }) .from(dealMeasureApprovals) - .where(eq(dealMeasureApprovals.hubspotDealId, dealId)); - const approvalCount = Number(approvalCountRows[0]?.count ?? 0); - // We just inserted (or upserted) one approval row in this tx, so any - // count > 1 means the deal already had a prior approval row before us. - const hadNoApprovals = approvalCount <= 1; + .where( + and( + eq(dealMeasureApprovals.hubspotDealId, dealId), + eq(dealMeasureApprovals.isApproved, true), + ), + ); + const allApprovedMeasureNames = approvedRows.map((r) => r.measureName); - return { instructedRowId, proposedWasEmpty, hadNoApprovals }; + return { instructedRowId, existingProposedMeasures, allApprovedMeasureNames }; }); }; @@ -240,25 +244,29 @@ export async function instructMeasure( // --------------------------------------------------------------------- const allInstructed = await readInstructed(input.dealId); + const mergedProposed = Array.from( + new Set([...txResult.existingProposedMeasures, measureName]), + ); + const instructedSync = await syncMeasuresField({ hubspotDealId: input.dealId, propName: INSTRUCTED_MEASURES_PROP, measureNames: allInstructed, }); - let proposedSync: { ok: true } | { ok: false; error: string } | null = null; - const shouldAutoPopulateProposed = - txResult.proposedWasEmpty && txResult.hadNoApprovals; - if (shouldAutoPopulateProposed) { - proposedSync = await syncMeasuresField({ - hubspotDealId: input.dealId, - propName: PROPOSED_MEASURES_PROP, - measureNames: [measureName], - }); - } + const proposedSync = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: PROPOSED_MEASURES_PROP, + measureNames: mergedProposed, + }); - const overallOk = - instructedSync.ok && (proposedSync === null || proposedSync.ok); + const approvedSync = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: APPROVED_MEASURES_PROP, + measureNames: txResult.allApprovedMeasureNames, + }); + + const overallOk = instructedSync.ok && proposedSync.ok && approvedSync.ok; if (overallOk) { try { @@ -273,21 +281,21 @@ export async function instructMeasure( ok: true, instructedRowId: txResult.instructedRowId, hubspotSync: "ok", - autoPopulatedProposed: shouldAutoPopulateProposed, }; } const hubspotError = !instructedSync.ok ? instructedSync.error - : proposedSync && !proposedSync.ok + : !proposedSync.ok ? proposedSync.error - : "HubSpot sync failed"; + : !approvedSync.ok + ? approvedSync.error + : "HubSpot sync failed"; return { ok: true, instructedRowId: txResult.instructedRowId, hubspotSync: "failed", - autoPopulatedProposed: shouldAutoPopulateProposed, hubspotError, }; } 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 2a6929b..e73316f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -22,7 +22,6 @@ import DocumentTable from "./DocumentTable"; import MeasuresTable from "./MeasuresTable"; import type { HubspotDeal } from "./types"; import PropertyDrawer from "./PropertyDrawer"; -import PropertyDetailDrawer, { type DrawerSection } from "./PropertyDetailDrawer"; import AnalyticsView from "./AnalyticsView"; import type { LiveTrackerProps, @@ -75,22 +74,6 @@ export default function LiveTracker({ dealname: null, }); - // ── Property detail drawer ─────────────────────────────────────────── - const [detailDeal, setDetailDeal] = useState(null); - const [detailFocusSection, setDetailFocusSection] = useState< - DrawerSection | undefined - >(undefined); - - const openDetailDrawer = (deal: ClassifiedDeal, section?: DrawerSection) => { - setDetailFocusSection(section); - setDetailDeal(deal); - }; - - const closeDetailDrawer = () => { - setDetailDeal(null); - setDetailFocusSection(undefined); - }; - const handleOpenTable = ( stage: string, filteredDeals: ClassifiedDeal[], @@ -243,7 +226,7 @@ export default function LiveTracker({ openDetailDrawer(deal)} + portfolioId={portfolioId} docStatusMap={docStatusMap} removalStatusByDeal={removalStatusByDeal} /> @@ -320,10 +303,8 @@ export default function LiveTracker({ )} openDetailDrawer(deal, "measures")} /> @@ -438,16 +419,6 @@ export default function LiveTracker({ } /> - {/* ── Property detail drawer ─────────────────────────────────────── */} - ); } 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 4ed29e9..9259fc4 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -1,7 +1,8 @@ "use client"; import React, { useMemo, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { Table, TableBody, @@ -11,13 +12,10 @@ import { TableRow, } from "@/app/shadcn_components/ui/table"; import { Input } from "@/app/shadcn_components/ui/input"; -import { Button } from "@/app/shadcn_components/ui/button"; import { Badge } from "@/app/shadcn_components/ui/badge"; -import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; -import { Search, Save, ChevronDown, ChevronRight } from "lucide-react"; +import { Search, ChevronDown, ChevronRight } from "lucide-react"; import { STAGE_COLORS } from "./types"; -import type { ClassifiedDeal, PortfolioCapabilityType, ApprovalsByDeal } from "./types"; -import { ApprovalConfirmDialog, type PendingDiff } from "./ApprovalConfirmDialog"; +import type { ClassifiedDeal, ApprovalsByDeal } from "./types"; import { parseMeasures } from "@/app/lib/parseMeasures"; type AuditEvent = { @@ -32,15 +30,8 @@ type AuditEvent = { type Props = { data: ClassifiedDeal[]; - userCapability: PortfolioCapabilityType; approvalsByDeal: ApprovalsByDeal; portfolioId: string; - /** - * Called when a measures row is clicked. The host (LiveTracker) opens the - * PropertyDetailDrawer focused on the Measures section. Optional so the - * table is still usable in isolation. - */ - onOpenDetail?: (deal: ClassifiedDeal) => void; }; function ApprovalStatus({ @@ -144,33 +135,13 @@ function ActivityLog({ ); } -async function postApprovalChanges( - portfolioId: string, - changes: { hubspotDealId: string; measureName: string; approved: boolean }[], -) { - const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ changes }), - }); - if (!res.ok) throw new Error("Failed to save approvals"); -} - export default function MeasuresTable({ data, - userCapability, approvalsByDeal, portfolioId, - onOpenDetail, }: Props) { + const router = useRouter(); const [search, setSearch] = useState(""); - // pendingChanges: dealId -> desired Set (the full intended approved set) - const [pendingChanges, setPendingChanges] = useState< - Record> - >({}); - const [savedApprovals, setSavedApprovals] = - useState(approvalsByDeal); - const [showConfirm, setShowConfirm] = useState(false); const [expandedRows, setExpandedRows] = useState>(new Set()); // Filter to only properties with proposed measures @@ -190,80 +161,6 @@ export default function MeasuresTable({ ); }, [dealsWithMeasures, search]); - const hasPendingChanges = Object.keys(pendingChanges).length > 0; - - // Compute diffs: for each deal in pendingChanges, what's added vs removed vs saved - const pendingDiffs = useMemo>(() => { - const diffs: Record = {}; - for (const [dealId, pending] of Object.entries(pendingChanges)) { - const saved = new Set(savedApprovals[dealId] ?? []); - const added = [...pending].filter((m) => !saved.has(m)); - const removed = [...saved].filter((m) => !pending.has(m)); - if (added.length > 0 || removed.length > 0) { - diffs[dealId] = { added, removed }; - } - } - return diffs; - }, [pendingChanges, savedApprovals]); - - const dealNames = useMemo>(() => { - const map: Record = {}; - for (const d of dealsWithMeasures) { - map[d.dealId] = d.dealname ?? d.landlordPropertyId ?? d.dealId; - } - return map; - }, [dealsWithMeasures]); - - const saveMutation = useMutation({ - mutationFn: () => { - // Build flat list of explicit changes from diffs - const changes: { hubspotDealId: string; measureName: string; approved: boolean }[] = []; - for (const [dealId, diff] of Object.entries(pendingDiffs)) { - for (const m of diff.added) changes.push({ hubspotDealId: dealId, measureName: m, approved: true }); - for (const m of diff.removed) changes.push({ hubspotDealId: dealId, measureName: m, approved: false }); - } - return postApprovalChanges(portfolioId, changes); - }, - onSuccess: () => { - setSavedApprovals((prev) => { - const next = { ...prev }; - for (const [dealId, pending] of Object.entries(pendingChanges)) { - next[dealId] = Array.from(pending); - } - return next; - }); - setPendingChanges({}); - setShowConfirm(false); - }, - }); - - function toggleMeasure(dealId: string, measure: string) { - setPendingChanges((prev) => { - const base = - prev[dealId] !== undefined - ? new Set(prev[dealId]) - : new Set(savedApprovals[dealId] ?? []); - - if (base.has(measure)) { - base.delete(measure); - } else { - base.add(measure); - } - - // If pending equals saved, remove from tracking - const saved = new Set(savedApprovals[dealId] ?? []); - const equal = base.size === saved.size && [...base].every((m) => saved.has(m)); - - const next = { ...prev }; - if (equal) { - delete next[dealId]; - } else { - next[dealId] = base; - } - return next; - }); - } - function toggleRowExpand(dealId: string) { setExpandedRows((prev) => { const next = new Set(prev); @@ -300,16 +197,9 @@ export default function MeasuresTable({ {filtered.length} of {dealsWithMeasures.length} properties - {userCapability.includes("approver") && hasPendingChanges && ( - - )} + + · Click a row to open property + @@ -336,23 +226,20 @@ export default function MeasuresTable({ {filtered.map((deal) => { const proposed = parseMeasures(deal.proposedMeasures); - const approvedForDeal = - pendingChanges[deal.dealId] !== undefined - ? Array.from(pendingChanges[deal.dealId]) - : (savedApprovals[deal.dealId] ?? []); + const approvedForDeal = approvalsByDeal[deal.dealId] ?? []; const approvedSet = new Set(approvedForDeal); const stageColor = STAGE_COLORS[deal.displayStage]; - const hasPending = pendingChanges[deal.dealId] !== undefined; const isExpanded = expandedRows.has(deal.dealId); + const dealPageUrl = `/portfolio/${portfolioId}/your-projects/live/${deal.dealId}?tab=works`; + const handleRowClick = () => { - if (onOpenDetail) onOpenDetail(deal); + router.push(dealPageUrl); }; const handleRowKeyDown = (e: React.KeyboardEvent) => { - if (!onOpenDetail) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - onOpenDetail(deal); + router.push(dealPageUrl); } }; @@ -360,11 +247,11 @@ export default function MeasuresTable({ {/* Expand toggle */} @@ -403,31 +290,11 @@ export default function MeasuresTable({ - {/* Proposed measures */} + {/* Proposed measures — read-only; click the row to approve in the drawer */}
{proposed.map((measure) => { const isApproved = approvedSet.has(measure); - if (userCapability.includes("approver")) { - return ( - - ); - } return (
- {/* Confirmation dialog */} - saveMutation.mutate()} - onCancel={() => setShowConfirm(false)} - isPending={saveMutation.isPending} - /> - ); } 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 daeb4c6..fd9fe62 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +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 { @@ -26,11 +26,13 @@ import { 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"; -// Sections that the drawer can scroll-focus on initial open. Keep the keys in -// stable, stage-ordered order so the layout remains predictable. +// 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" @@ -39,12 +41,32 @@ export type DrawerSection = | "halted" | "technical"; +// 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", +}; + +const TAB_LABELS: Record = { + overview: "Overview", + works: "Works", + pibi: "PIBI", + "survey-admin": "Survey & Admin", +}; + // ----------------------------------------------------------------------- // Removal request section // ----------------------------------------------------------------------- -const WRITE_ROLES = ["creator", "admin", "write"]; +export const WRITE_ROLES = ["creator", "admin", "write"]; -function RemovalRequestSection({ +export function RemovalRequestSection({ dealId, portfolioId, userRole, @@ -335,10 +357,156 @@ function RemovalRequestSection({ ); } +// ----------------------------------------------------------------------- +// Survey request section — write-role users can request a survey from Domna +// ----------------------------------------------------------------------- +type SurveyRequestRecord = { + id: string; + hubspotDealId: string; + notes: string; + status: string; + requestedByEmail: string; + requestedAt: string | null; + fulfilledAt: string | null; +}; + +export function SurveyRequestSection({ + dealId, + portfolioId, + userRole, +}: { + dealId: string; + portfolioId: string; + userRole: string; +}) { + const queryClient = useQueryClient(); + const [notes, setNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const canRequest = WRITE_ROLES.includes(userRole as (typeof WRITE_ROLES)[number]); + + const { data, isLoading } = useQuery<{ requests: SurveyRequestRecord[] }>({ + queryKey: ["surveyRequests", portfolioId, dealId], + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/survey-requests?dealId=${encodeURIComponent(dealId)}`, + ); + if (!res.ok) throw new Error("Failed to fetch survey requests"); + return res.json(); + }, + staleTime: 30_000, + }); + + const pending = data?.requests?.find((r) => r.status === "pending") ?? null; + + async function handleSubmit() { + if (!notes.trim() || submitting) return; + setSubmitting(true); + setError(null); + try { + const res = await fetch(`/api/portfolio/${portfolioId}/survey-requests`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hubspotDealId: dealId, notes: notes.trim() }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + setError(typeof json.error === "string" ? json.error : "Failed to submit request"); + return; + } + const json = (await res.json()) as { ok: boolean; hubspotSync?: string; hubspotError?: string }; + if (json.hubspotSync === "failed") { + setError(json.hubspotError ?? "Saved locally — HubSpot sync failed"); + } + setNotes(""); + queryClient.invalidateQueries({ queryKey: ["surveyRequests", portfolioId, dealId] }); + } finally { + setSubmitting(false); + } + } + + if (isLoading) { + return

Loading…

; + } + + return ( +
+ {error && ( +

{error}

+ )} + + {/* Pending badge */} + {pending && ( +
+ + Survey Requested + +

{pending.notes}

+

+ Requested by{" "} + {pending.requestedByEmail} + {pending.requestedAt && ` · ${formatDateTime(pending.requestedAt)}`} +

+
+ )} + + {/* Request form — only shown when no pending request */} + {canRequest && !pending && ( +
+

+ Request a survey from Domna. Notes will be sent to the coordination team. +

+