Merge pull request #248 from Hestia-Homes/feature/additional-db-columns

added hubspot user table
This commit is contained in:
KhalimCK 2026-05-06 15:35:31 +01:00 committed by GitHub
commit a456be3c54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 25758 additions and 340 deletions

View file

@ -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

View file

@ -0,0 +1,102 @@
/**
* Live Tracking Domna Survey editor (issue #256)
*
* Verifies the approver flow on the Domna section of the property detail
* drawer:
* 1. an approver can set a Domna survey type (free text) and date and
* save them,
* 2. the drawer reflects the saved values immediately (optimistic
* update),
* 3. the values persist across a page reload (i.e. the deal-properties
* endpoint wrote them server-side).
*
* Mirrors `halted-state.cy.js`. Assumes an authenticated approver session
* is reusable by the test harness; the target portfolio + a deal whose
* Domna section is editable by the current 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_DOMNA_DEAL_NAME");
const SURVEY_TYPE = "Standard";
const SURVEY_DATE = "2025-07-15";
describe("Domna survey editor — approver 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 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");
}
it("lets an approver set domna survey type + date and persists them across reload", () => {
openDrawerForTargetDeal();
// Approver sees editable inputs.
cy.get("[data-testid=domna-survey-type-input]").should("be.visible");
cy.get("[data-testid=domna-survey-date-input]").should("be.visible");
cy.get("[data-testid=domna-survey-type-input]")
.clear()
.type(SURVEY_TYPE);
cy.get("[data-testid=domna-survey-date-input]")
.clear()
.type(SURVEY_DATE);
cy.get("[data-testid=domna-save-button]")
.should("not.be.disabled")
.click();
// Save completes — button label flips back, no error banner.
cy.get("[data-testid=domna-save-button]").should(
"contain.text",
"Save Domna Survey",
);
cy.get("[data-testid=domna-error]").should("not.exist");
// Optimistic update — the inputs already reflect the new values.
cy.get("[data-testid=domna-survey-type-input]").should(
"have.value",
SURVEY_TYPE,
);
cy.get("[data-testid=domna-survey-date-input]").should(
"have.value",
SURVEY_DATE,
);
// Reload the page and reopen the drawer — the persisted values must
// still be there.
cy.reload();
openDrawerForTargetDeal();
cy.get("[data-testid=domna-survey-type-input]").should(
"have.value",
SURVEY_TYPE,
);
cy.get("[data-testid=domna-survey-date-input]").should(
"have.value",
SURVEY_DATE,
);
});
});

View file

@ -0,0 +1,109 @@
/**
* Live Tracking Halted state editor (issue #255)
*
* Verifies the approver flow on the Halted section of the property detail
* drawer:
* 1. an approver can set a halted date + free-text reason and save them,
* 2. the drawer reflects the halted state (badge + persisted values),
* 3. clicking Resume clears the date but keeps the reason as the
* last-set value, both in the input and after a reload.
*
* Mirrors `pibi-dates.cy.js`. Assumes an authenticated approver session
* is reusable by the test harness; the target portfolio + a deal whose
* Halted section is editable by the current 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_HALTED_DEAL_NAME");
const HALTED_DATE = "2025-06-01";
const HALTED_REASON = "Awaiting roof access from landlord";
describe("Halted state editor — approver 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 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");
}
it("lets an approver halt a property and resume it while preserving the reason", () => {
openDrawerForTargetDeal();
// Approver sees editable inputs.
cy.get("[data-testid=halted-date-input]").should("be.visible");
cy.get("[data-testid=halted-reason-input]").should("be.visible");
// Set halted date + reason.
cy.get("[data-testid=halted-date-input]").clear().type(HALTED_DATE);
cy.get("[data-testid=halted-reason-input]")
.clear()
.type(HALTED_REASON);
cy.get("[data-testid=halted-save-button]")
.should("not.be.disabled")
.click();
// Save completes — button label flips back, no error banner.
cy.get("[data-testid=halted-save-button]").should(
"contain.text",
"Save Halted State",
);
cy.get("[data-testid=halted-error]").should("not.exist");
// Drawer reflects halted state via the status badge + persisted values.
cy.get("[data-testid=halted-status-badge]").should("contain.text", "Halted");
cy.get("[data-testid=halted-date-input]").should("have.value", HALTED_DATE);
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
// Now resume — date clears, reason stays.
cy.get("[data-testid=halted-resume-button]")
.should("be.visible")
.click();
// Once resumed the badge + resume button disappear, but the reason is
// still visible in the textarea.
cy.get("[data-testid=halted-status-badge]").should("not.exist");
cy.get("[data-testid=halted-resume-button]").should("not.exist");
cy.get("[data-testid=halted-date-input]").should("have.value", "");
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
// Reload the page — the cleared date and preserved reason persist
// server-side.
cy.reload();
openDrawerForTargetDeal();
cy.get("[data-testid=halted-date-input]").should("have.value", "");
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
});
});

View file

@ -0,0 +1,93 @@
/**
* Live Tracking Instruct measure flow (issue #253)
*
* Verifies the approver flow for instructing a measure that the
* coordinator did not propose:
* 1. the approver opens the property drawer at the Measures section,
* 2. picks a measure from the canonical catalogue dropdown and submits,
* 3. the drawer reflects the new instructed measure (optimistic chip),
* 4. the POST hits the instructed-measures route which pushes
* `instructed_measures` back to HubSpot,
* 5. the approval log surface shows a row for the new approval.
*
* Mirrors `halted-state.cy.js` / `domna-survey.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_INSTRUCT_DEAL_NAME");
const INSTRUCT_MEASURE = "Loft insulation";
describe("Instruct measure — approver 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 at the
// Measures section.
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-section-measures]").should("exist");
}
it("lets an approver instruct a measure and reflects it in the drawer + approval log", () => {
// Capture the API call so we can assert the payload that would be
// pushed to HubSpot under `instructed_measures`.
cy.intercept(
"POST",
`/api/portfolio/*/instructed-measures`,
).as("instructMeasure");
openDrawerForTargetDeal();
// Approver-only form is visible at the bottom of the Measures section.
cy.get("[data-testid=instruct-measure-select]").should("be.visible");
cy.get("[data-testid=instruct-measure-select]").select(INSTRUCT_MEASURE);
cy.get("[data-testid=instruct-measure-submit]")
.should("not.be.disabled")
.click();
// Wait for the POST to land and assert the body shape that the
// service uses to drive the HubSpot push.
cy.wait("@instructMeasure").then((intercepted) => {
expect(intercepted.request.body).to.deep.include({
measureName: INSTRUCT_MEASURE,
});
// Response from our route signals the HubSpot sync outcome — it is
// either "ok" (mock recorded the push) or "failed" (network error).
// We accept either here so the spec stays portable across envs.
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");
});
// Drawer reflects the instructed measure as an optimistic chip.
cy.get("[data-testid=instructed-measures-list]").should("be.visible");
cy.get("[data-testid=instructed-measure-chip]")
.should("contain.text", INSTRUCT_MEASURE);
// No error banner.
cy.get("[data-testid=instruct-measure-error]").should("not.exist");
// Approval log section reveals the new approval row when expanded.
cy.contains("Approval Log").click();
cy.contains(INSTRUCT_MEASURE).should("exist");
});
});

View file

@ -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");
});
});

View file

@ -0,0 +1,100 @@
/**
* 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,
);
});
});

View file

@ -0,0 +1,162 @@
/**
* 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");
});
});
});

View file

@ -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",
);
});
});

View file

@ -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");
});
});

View file

@ -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");
});
});

1297
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e:open": "start-server-and-test dev http://localhost:3000 \"cypress open --e2e\"",
"test:e2e:run": "cypress run",
"migration:generate": "drizzle-kit generate",
@ -91,6 +93,7 @@
"drizzle-kit": "^0.31.5",
"eslint": "^8.57.1",
"prettier": "^3.6.2",
"start-server-and-test": "^2.0.0"
"start-server-and-test": "^2.0.0",
"vitest": "^2.1.9"
}
}

View file

@ -0,0 +1,132 @@
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 { applyDealPropertyUpdate } from "@/app/lib/dealPropertyUpdate";
const patchSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
fields: z.record(z.unknown()),
});
/**
* PATCH /api/portfolio/[portfolioId]/deal-properties
*
* Single update path for whitelisted, role-gated fields on
* `hubspot_deal_data`. The route is responsible for AuthN + portfolio role
* lookup; per-field validation, permission check, DB write and HubSpot
* push are delegated to `applyDealPropertyUpdate`.
*
* Body shape:
* {
* "dealId": "12345",
* "fields": {
* "pibi_order_date": "2025-03-12T00:00:00.000Z" | null,
* "pibi_completed_date": "2025-04-02T00:00:00.000Z" | null,
* ...
* }
* }
*
* Response:
* 200 { results: { [field]: { ok: true } | { ok: false, error } },
* hubspotSync: "ok" | "failed" | "skipped",
* hubspotError?: string }
*/
export async function PATCH(
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 = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 },
);
}
const { dealId, fields } = parsed.data;
// Look up the calling user's role on this portfolio. The service
// enforces per-field permissions but we still need a role string to
// pass through.
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);
const role = portfolioUserRow[0]?.role;
if (!role) {
return NextResponse.json(
{ error: "No portfolio access" },
{ status: 403 },
);
}
// Capabilities are orthogonal to role — used by approver-gated fields
// (e.g. property_halted_date / _reason in issue #255).
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
const capabilities = capabilityRows.map((r) => r.capability);
try {
const outcome = await applyDealPropertyUpdate({
dealId,
fields,
role,
capabilities,
});
return NextResponse.json(outcome);
} catch (err) {
console.error("[deal-properties PATCH]", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,145 @@
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 { instructMeasure } from "@/app/lib/instructMeasure";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
const postSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
measureName: z.string().min(1, "measureName is required"),
});
/**
* POST /api/portfolio/[portfolioId]/instructed-measures
*
* Approver-only endpoint that instructs a measure on a deal. Validates the
* measure name against the canonical `MEASURE_NAMES` catalogue, persists
* to `user_defined_deal_measures`, auto-creates an approval row, and
* pushes back to HubSpot. See `instructMeasure` for the full contract.
*
* Body:
* { dealId: string, measureName: string }
*
* Response:
* 200 { ok: true, hubspotSync: "ok" | "failed", autoPopulatedProposed: boolean, hubspotError? }
* 400 { ok: false, error }
* 401 / 403 / 404 on auth/role/user errors.
*/
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 { dealId, measureName } = parsed.data;
// Validate against the canonical catalogue up-front so the route returns
// a clean 400 rather than relying on the service-level check.
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(measureName)) {
return NextResponse.json(
{ error: `Unknown measure: ${measureName}` },
{ 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 });
}
// Caller must have any role on the portfolio (so we don't expose the
// endpoint to strangers) AND the approver capability.
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return NextResponse.json(
{ error: "No portfolio access" },
{ status: 403 },
);
}
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
const capabilities = capabilityRows.map((r) => r.capability);
if (!capabilities.includes("approver")) {
return NextResponse.json(
{ error: "Approver capability required" },
{ status: 403 },
);
}
try {
const result = await instructMeasure({
dealId,
measureName,
userId: userRow[0].id,
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, {
status: 400,
});
}
return NextResponse.json({
ok: true,
hubspotSync: result.hubspotSync,
hubspotError: result.hubspotError,
});
} catch (err) {
console.error("[instructed-measures POST]", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,231 @@
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 { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { selectPibiMeasures } from "@/app/lib/selectPibiMeasures";
const postSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
measureNames: z.array(z.string()).min(0),
});
/**
* GET /api/portfolio/[portfolioId]/pibi-measures?dealId=...
*
* Returns the current PIBI selection and approved measure names for a deal.
* Used by the drawer's PIBI selector to pre-populate the multi-select.
*
* Response:
* 200 { pibiMeasures: string[], approvedMeasures: string[], instructedMeasures: string[] }
*/
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 [pibiRows, approvalRows, instructedRows] = await Promise.all([
db
.select({ measureName: userDefinedDealMeasures.measureName })
.from(userDefinedDealMeasures)
.where(
and(
eq(userDefinedDealMeasures.hubspotDealId, dealId),
eq(userDefinedDealMeasures.source, "pibi_ordered"),
),
),
db
.select({ measureName: dealMeasureApprovals.measureName })
.from(dealMeasureApprovals)
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
),
db
.select({ measureName: userDefinedDealMeasures.measureName })
.from(userDefinedDealMeasures)
.where(
and(
eq(userDefinedDealMeasures.hubspotDealId, dealId),
eq(userDefinedDealMeasures.source, "instructed"),
),
),
]);
return NextResponse.json({
pibiMeasures: pibiRows.map((r) => r.measureName),
approvedMeasures: approvalRows.map((r) => r.measureName),
instructedMeasures: instructedRows.map((r) => r.measureName),
});
}
/**
* POST /api/portfolio/[portfolioId]/pibi-measures
*
* Approver-only endpoint that records which measures on a deal are going for
* PIBI. The incoming `measureNames[]` is the FULL desired set it replaces
* any prior selection. Persists to `user_defined_deal_measures` with
* `source = "pibi_ordered"` and pushes back to HubSpot under
* `measures_for_pibi_ordered`. See `selectPibiMeasures` for the full
* contract.
*
* Body:
* { dealId: string, measureNames: string[] }
*
* Response:
* 200 { ok: true, hubspotSync: "ok" | "failed", hubspotError? }
* 400 { ok: false, error }
* 401 / 403 / 404 on auth/role/user errors.
*/
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 { dealId, measureNames } = parsed.data;
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 });
}
// Caller must be a portfolio member AND have the approver capability.
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return NextResponse.json(
{ error: "No portfolio access" },
{ status: 403 },
);
}
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
const capabilities = capabilityRows.map((r) => r.capability);
if (!capabilities.includes("approver")) {
return NextResponse.json(
{ error: "Approver capability required" },
{ status: 403 },
);
}
try {
const result = await selectPibiMeasures({
dealId,
measureNames,
userId: userRow[0].id,
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, {
status: 400,
});
}
return NextResponse.json({
ok: true,
hubspotSync: result.hubspotSync,
hubspotError: result.hubspotError,
});
} catch (err) {
console.error("[pibi-measures POST]", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}

View file

@ -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 });
}
}

View file

@ -0,0 +1,7 @@
CREATE TABLE "hubspot_users" (
"hubspot_owner_id" text PRIMARY KEY NOT NULL,
"first_name" text,
"last_name" text,
"email" text,
"updated_at" timestamp (6) with time zone NOT NULL
);

View file

@ -0,0 +1 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_type" text;

View file

@ -0,0 +1,16 @@
CREATE TYPE "public"."user_defined_deal_measure_source" AS ENUM('instructed', 'pibi_ordered');--> statement-breakpoint
CREATE TABLE "user_defined_deal_measures" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"measure_name" text NOT NULL,
"source" "user_defined_deal_measure_source" NOT NULL,
"created_by_user_id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"pushed_at" timestamp with time zone,
"confirmed_in_hubspot_at" timestamp with time zone,
"notes" text
);
--> statement-breakpoint
ALTER TABLE "user_defined_deal_measures" ADD CONSTRAINT "user_defined_deal_measures_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_user_defined_deal_measures_deal_id" ON "user_defined_deal_measures" USING btree ("hubspot_deal_id");--> statement-breakpoint
CREATE INDEX "idx_user_defined_deal_measures_source" ON "user_defined_deal_measures" USING btree ("source");

View file

@ -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");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1345,6 +1345,27 @@
"when": 1777560763716,
"tag": "0191_soft_ezekiel_stane",
"breakpoints": true
},
{
"idx": 192,
"version": "7",
"when": 1777576507360,
"tag": "0192_colorful_quasimodo",
"breakpoints": true
},
{
"idx": 193,
"version": "7",
"when": 1777750000000,
"tag": "0193_domna_survey_type",
"breakpoints": true
},
{
"idx": 194,
"version": "7",
"when": 1778100000000,
"tag": "0194_user_defined_deal_measures",
"breakpoints": true
}
]
}

View file

@ -66,7 +66,8 @@ 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"),
domnasurveyRequired: boolean("domna_survey_required"),
domnaSurveyType: text("domna_survey_type"),
domnaSurveyDate: timestamp("domna_survey_date", { precision: 6, withTimezone: true }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })

View file

@ -0,0 +1,13 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
export const hubspotUsers = pgTable("hubspot_users", {
hubspotOwnerId: text("hubspot_owner_id").primaryKey(),
firstName: text("first_name"),
lastName: text("last_name"),
email: text("email"),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true }).notNull(),
});
export type HubspotUser = InferModel<typeof hubspotUsers>;
export type NewHubspotUser = InferModel<typeof hubspotUsers, "insert">;

View file

@ -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;

View file

@ -0,0 +1,67 @@
/**
* User-defined deal measures (issue #253)
*
* Tracks measures that an approver has *instructed* outside the coordinator's
* proposed set, plus measures that have been *PIBI-ordered* by the contractor
* (slice 4). The `source` enum distinguishes the two flows so a single table
* can back both.
*
* Each row pushes back to HubSpot under one of two new multi-value text deal
* properties (`instructed_measures` / `pibi_ordered_measures`). `pushed_at`
* is stamped after a successful sync; `confirmed_in_hubspot_at` is stamped
* by a follow-up reconcile job (out of scope for this slice).
*/
import {
bigserial,
text,
timestamp,
pgTable,
bigint,
index,
pgEnum,
} from "drizzle-orm/pg-core";
import { user } from "./users";
import { InferModel } from "drizzle-orm";
export const UserDefinedDealMeasureSource: [string, ...string[]] = [
"instructed",
"pibi_ordered",
];
export type UserDefinedDealMeasureSourceType =
| "instructed"
| "pibi_ordered";
export const userDefinedDealMeasureSourceEnum = pgEnum(
"user_defined_deal_measure_source",
UserDefinedDealMeasureSource as [string, ...string[]],
);
export const userDefinedDealMeasures = pgTable(
"user_defined_deal_measures",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
hubspotDealId: text("hubspot_deal_id").notNull(),
measureName: text("measure_name").notNull(),
source: userDefinedDealMeasureSourceEnum("source").notNull(),
createdByUserId: bigint("created_by_user_id", { mode: "bigint" })
.notNull()
.references(() => user.id),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
pushedAt: timestamp("pushed_at", { withTimezone: true }),
confirmedInHubspotAt: timestamp("confirmed_in_hubspot_at", {
withTimezone: true,
}),
notes: text("notes"),
},
(table) => [
index("idx_user_defined_deal_measures_deal_id").on(table.hubspotDealId),
index("idx_user_defined_deal_measures_source").on(table.source),
],
);
export type UserDefinedDealMeasure = InferModel<
typeof userDefinedDealMeasures,
"select"
>;

View file

@ -0,0 +1,478 @@
import { describe, expect, it, vi } from "vitest";
import {
applyDealPropertyUpdate,
DEAL_PROPERTY_FIELDS,
isDealPropertyField,
roleAllowedForField,
} from "./dealPropertyUpdate";
describe("DEAL_PROPERTY_FIELDS registry", () => {
it("exposes the two PIBI date fields with write-or-above permissions", () => {
expect(isDealPropertyField("pibi_order_date")).toBe(true);
expect(isDealPropertyField("pibi_completed_date")).toBe(true);
expect(roleAllowedForField("pibi_order_date", "read")).toBe(false);
expect(roleAllowedForField("pibi_order_date", "write")).toBe(true);
expect(roleAllowedForField("pibi_order_date", "admin")).toBe(true);
expect(roleAllowedForField("pibi_order_date", "creator")).toBe(true);
expect(roleAllowedForField("pibi_completed_date", "read")).toBe(false);
expect(roleAllowedForField("pibi_completed_date", "write")).toBe(true);
});
it("exposes the halted fields gated on the approver capability", () => {
expect(isDealPropertyField("property_halted_date")).toBe(true);
expect(isDealPropertyField("property_halted_reason")).toBe(true);
// Plain roles — even the most permissive — never satisfy the approver
// gate on their own.
for (const role of ["read", "write", "admin", "creator"]) {
expect(roleAllowedForField("property_halted_date", role)).toBe(false);
expect(roleAllowedForField("property_halted_reason", role)).toBe(false);
}
// Capability list grants access regardless of role tier.
expect(
roleAllowedForField("property_halted_date", "read", ["approver"]),
).toBe(true);
expect(
roleAllowedForField("property_halted_reason", "write", ["approver"]),
).toBe(true);
// Other capabilities should not unlock the field.
expect(
roleAllowedForField("property_halted_date", "write", ["contractor"]),
).toBe(false);
});
it("exposes the domna survey fields gated on the approver capability", () => {
expect(isDealPropertyField("domna_survey_type")).toBe(true);
expect(isDealPropertyField("domna_survey_date")).toBe(true);
// Plain roles never satisfy the approver gate on their own.
for (const role of ["read", "write", "admin", "creator"]) {
expect(roleAllowedForField("domna_survey_type", role)).toBe(false);
expect(roleAllowedForField("domna_survey_date", role)).toBe(false);
}
// Approver capability unlocks both.
expect(
roleAllowedForField("domna_survey_type", "read", ["approver"]),
).toBe(true);
expect(
roleAllowedForField("domna_survey_date", "read", ["approver"]),
).toBe(true);
// Other capabilities do not unlock the fields.
expect(
roleAllowedForField("domna_survey_type", "write", ["contractor"]),
).toBe(false);
});
it("maps each registered field to the matching HubSpot property", () => {
expect(DEAL_PROPERTY_FIELDS.pibi_order_date.hubspotProperty).toBe(
"pibi_order_date",
);
expect(DEAL_PROPERTY_FIELDS.pibi_completed_date.hubspotProperty).toBe(
"pibi_completed_date",
);
expect(DEAL_PROPERTY_FIELDS.property_halted_date.hubspotProperty).toBe(
"property_halted_date",
);
expect(DEAL_PROPERTY_FIELDS.property_halted_reason.hubspotProperty).toBe(
"property_halted_reason",
);
expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe(
"domna_survey_type",
);
expect(DEAL_PROPERTY_FIELDS.domna_survey_date.hubspotProperty).toBe(
"domna_survey_date",
);
});
it("rejects unknown fields", () => {
expect(isDealPropertyField("not_a_field")).toBe(false);
});
});
describe("applyDealPropertyUpdate", () => {
it("rejects non-whitelisted fields without writing or syncing", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-1",
fields: { unknown_field: "x" },
role: "write",
deps: { updateDb, pushHubspot },
});
expect(out.results.unknown_field).toEqual({
ok: false,
error: "Field not editable",
});
expect(out.hubspotSync).toBe("skipped");
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("rejects PIBI fields when the user role is read", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-1",
fields: { pibi_order_date: "2025-01-15" },
role: "read",
deps: { updateDb, pushHubspot },
});
expect(out.results.pibi_order_date).toEqual({
ok: false,
error: "Insufficient permissions",
});
expect(out.hubspotSync).toBe("skipped");
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("rejects values that fail validation", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-1",
fields: { pibi_order_date: "not-a-real-date" },
role: "write",
deps: { updateDb, pushHubspot },
});
expect(out.results.pibi_order_date.ok).toBe(false);
expect(out.hubspotSync).toBe("skipped");
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("persists valid fields to the DB and pushes them to HubSpot", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
const orderIso = "2025-03-12T00:00:00.000Z";
const completedIso = "2025-04-02T00:00:00.000Z";
const out = await applyDealPropertyUpdate({
dealId: "deal-42",
fields: {
pibi_order_date: orderIso,
pibi_completed_date: completedIso,
},
role: "write",
deps: { updateDb, pushHubspot },
});
expect(out.results.pibi_order_date).toEqual({ ok: true });
expect(out.results.pibi_completed_date).toEqual({ ok: true });
expect(out.hubspotSync).toBe("ok");
expect(updateDb).toHaveBeenCalledTimes(1);
const [dealIdArg, valuesArg] = updateDb.mock.calls[0];
expect(dealIdArg).toBe("deal-42");
expect(valuesArg.pibiOrderDate).toBeInstanceOf(Date);
expect((valuesArg.pibiOrderDate as Date).toISOString()).toBe(orderIso);
expect((valuesArg.pibiCompletedDate as Date).toISOString()).toBe(
completedIso,
);
expect(pushHubspot).toHaveBeenCalledTimes(1);
const pushArg = pushHubspot.mock.calls[0][0];
expect(pushArg.hubspotDealId).toBe("deal-42");
// HubSpot expects epoch milliseconds as strings for date properties.
expect(pushArg.properties.pibi_order_date).toBe(
String(new Date(orderIso).getTime()),
);
expect(pushArg.properties.pibi_completed_date).toBe(
String(new Date(completedIso).getTime()),
);
});
it("clears a date when null is supplied (sends empty string to HubSpot)", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
const out = await applyDealPropertyUpdate({
dealId: "deal-7",
fields: { pibi_order_date: null },
role: "admin",
deps: { updateDb, pushHubspot },
});
expect(out.results.pibi_order_date).toEqual({ ok: true });
expect(out.hubspotSync).toBe("ok");
expect(updateDb.mock.calls[0][1].pibiOrderDate).toBeNull();
expect(pushHubspot.mock.calls[0][0].properties.pibi_order_date).toBe("");
});
it("rejects halted fields when the caller lacks approver capability", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-h1",
fields: {
property_halted_date: "2025-06-01T00:00:00.000Z",
property_halted_reason: "Awaiting access",
},
// Even creator role alone is not enough — capability is orthogonal.
role: "creator",
capabilities: [],
deps: { updateDb, pushHubspot },
});
expect(out.results.property_halted_date).toEqual({
ok: false,
error: "Insufficient permissions",
});
expect(out.results.property_halted_reason).toEqual({
ok: false,
error: "Insufficient permissions",
});
expect(out.hubspotSync).toBe("skipped");
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("persists halted date + reason for an approver and pushes them to HubSpot", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
const haltedIso = "2025-06-01T00:00:00.000Z";
const reason = "Awaiting roof access";
const out = await applyDealPropertyUpdate({
dealId: "deal-h2",
fields: {
property_halted_date: haltedIso,
property_halted_reason: reason,
},
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.property_halted_date).toEqual({ ok: true });
expect(out.results.property_halted_reason).toEqual({ ok: true });
expect(out.hubspotSync).toBe("ok");
const dbValues = updateDb.mock.calls[0][1];
expect(dbValues.propertyHaltedDate).toBeInstanceOf(Date);
expect((dbValues.propertyHaltedDate as Date).toISOString()).toBe(haltedIso);
expect(dbValues.propertyHaltedReason).toBe(reason);
const props = pushHubspot.mock.calls[0][0].properties;
expect(props.property_halted_date).toBe(
String(new Date(haltedIso).getTime()),
);
expect(props.property_halted_reason).toBe(reason);
});
it("validates the halted date format", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-h3",
fields: { property_halted_date: "definitely-not-a-date" },
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.property_halted_date.ok).toBe(false);
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("collapses an empty halted reason to null on save", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
const out = await applyDealPropertyUpdate({
dealId: "deal-h4",
fields: { property_halted_reason: "" },
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.property_halted_reason).toEqual({ ok: true });
expect(updateDb.mock.calls[0][1].propertyHaltedReason).toBeNull();
expect(pushHubspot.mock.calls[0][0].properties.property_halted_reason).toBe(
"",
);
});
it("resume clears the halted date and leaves the reason untouched in DB + HubSpot payload", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
// The drawer's "Resume" action only sends `property_halted_date: null`.
// The reason field is omitted entirely so the existing value is
// preserved.
const out = await applyDealPropertyUpdate({
dealId: "deal-h5",
fields: { property_halted_date: null },
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.property_halted_date).toEqual({ ok: true });
expect(out.results.property_halted_reason).toBeUndefined();
expect(out.hubspotSync).toBe("ok");
const dbValues = updateDb.mock.calls[0][1];
expect(dbValues.propertyHaltedDate).toBeNull();
// No reason key at all → reason column not touched.
expect("propertyHaltedReason" in dbValues).toBe(false);
const props = pushHubspot.mock.calls[0][0].properties;
expect(props.property_halted_date).toBe("");
// No reason key in the HubSpot payload → reason not pushed.
expect("property_halted_reason" in props).toBe(false);
});
it("rejects domna fields when the caller lacks approver capability", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-d1",
fields: {
domna_survey_type: "Standard",
domna_survey_date: "2025-07-15T00:00:00.000Z",
},
// Even creator role alone is not enough — capability is orthogonal.
role: "creator",
capabilities: [],
deps: { updateDb, pushHubspot },
});
expect(out.results.domna_survey_type).toEqual({
ok: false,
error: "Insufficient permissions",
});
expect(out.results.domna_survey_date).toEqual({
ok: false,
error: "Insufficient permissions",
});
expect(out.hubspotSync).toBe("skipped");
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("persists domna survey type + date for an approver and pushes them to HubSpot", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
const surveyType = "Detailed";
const surveyIso = "2025-07-15T00:00:00.000Z";
const out = await applyDealPropertyUpdate({
dealId: "deal-d2",
fields: {
domna_survey_type: surveyType,
domna_survey_date: surveyIso,
},
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.domna_survey_type).toEqual({ ok: true });
expect(out.results.domna_survey_date).toEqual({ ok: true });
expect(out.hubspotSync).toBe("ok");
const dbValues = updateDb.mock.calls[0][1];
expect(dbValues.domnaSurveyType).toBe(surveyType);
expect(dbValues.domnaSurveyDate).toBeInstanceOf(Date);
expect((dbValues.domnaSurveyDate as Date).toISOString()).toBe(surveyIso);
const props = pushHubspot.mock.calls[0][0].properties;
expect(props.domna_survey_type).toBe(surveyType);
expect(props.domna_survey_date).toBe(
String(new Date(surveyIso).getTime()),
);
});
it("validates the domna survey date format", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-d3",
fields: { domna_survey_date: "definitely-not-a-date" },
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.domna_survey_date.ok).toBe(false);
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("lets domna survey type and date be settable independently", async () => {
// Setting only the type — date column is untouched.
const updateDbType = vi.fn().mockResolvedValue(undefined);
const pushHubspotType = vi.fn().mockResolvedValue({ ok: true });
const outType = await applyDealPropertyUpdate({
dealId: "deal-d4",
fields: { domna_survey_type: "Standard" },
role: "read",
capabilities: ["approver"],
deps: { updateDb: updateDbType, pushHubspot: pushHubspotType },
});
expect(outType.results.domna_survey_type).toEqual({ ok: true });
expect(outType.results.domna_survey_date).toBeUndefined();
const typeOnlyDb = updateDbType.mock.calls[0][1];
expect(typeOnlyDb.domnaSurveyType).toBe("Standard");
expect("domnaSurveyDate" in typeOnlyDb).toBe(false);
const typeOnlyProps = pushHubspotType.mock.calls[0][0].properties;
expect(typeOnlyProps.domna_survey_type).toBe("Standard");
expect("domna_survey_date" in typeOnlyProps).toBe(false);
// Setting only the date — type column is untouched.
const updateDbDate = vi.fn().mockResolvedValue(undefined);
const pushHubspotDate = vi.fn().mockResolvedValue({ ok: true });
const surveyIso = "2025-08-20T00:00:00.000Z";
const outDate = await applyDealPropertyUpdate({
dealId: "deal-d4b",
fields: { domna_survey_date: surveyIso },
role: "read",
capabilities: ["approver"],
deps: { updateDb: updateDbDate, pushHubspot: pushHubspotDate },
});
expect(outDate.results.domna_survey_date).toEqual({ ok: true });
expect(outDate.results.domna_survey_type).toBeUndefined();
const dateOnlyDb = updateDbDate.mock.calls[0][1];
expect(dateOnlyDb.domnaSurveyDate).toBeInstanceOf(Date);
expect("domnaSurveyType" in dateOnlyDb).toBe(false);
const dateOnlyProps = pushHubspotDate.mock.calls[0][0].properties;
expect(dateOnlyProps.domna_survey_date).toBe(
String(new Date(surveyIso).getTime()),
);
expect("domna_survey_type" in dateOnlyProps).toBe(false);
});
it("clears both domna fields to null when explicitly cleared", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
const out = await applyDealPropertyUpdate({
dealId: "deal-d5",
fields: {
// empty string collapses to null (mirrors the date schema)
domna_survey_type: "",
domna_survey_date: null,
},
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.domna_survey_type).toEqual({ ok: true });
expect(out.results.domna_survey_date).toEqual({ ok: true });
const dbValues = updateDb.mock.calls[0][1];
expect(dbValues.domnaSurveyType).toBeNull();
expect(dbValues.domnaSurveyDate).toBeNull();
const props = pushHubspot.mock.calls[0][0].properties;
expect(props.domna_survey_type).toBe("");
expect(props.domna_survey_date).toBe("");
});
it("surfaces HubSpot push failures back to the caller", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi
.fn()
.mockResolvedValue({ ok: false, error: "boom" });
const out = await applyDealPropertyUpdate({
dealId: "deal-9",
fields: { pibi_order_date: "2025-05-01T00:00:00.000Z" },
role: "write",
deps: { updateDb, pushHubspot },
});
expect(out.results.pibi_order_date).toEqual({ ok: true });
expect(out.hubspotSync).toBe("failed");
expect(out.hubspotError).toBe("boom");
// DB update still happened — UI gets the optimistic value, error is
// surfaced separately.
expect(updateDb).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,361 @@
/**
* Deal-property update service (issue #252)
*
* Centralised, whitelist-driven helper for the
* `PATCH /api/portfolio/[portfolioId]/deal-properties` endpoint. Each
* editable field on `hubspot_deal_data` is registered here once with:
*
* - the role required to write it
* - a Zod parser that validates + coerces the inbound value
* - the matching HubSpot deal property name (used by the sync push)
* - the Drizzle column to update
*
* Slice 5 (this issue) ships the two PIBI date fields. Slices 6 and 7
* (issues #255 / #256) plug the halted state and Domna survey fields into
* the same registry without changing the route, the service entry point or
* the per-field permission logic.
*/
import { z, ZodTypeAny } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { getHubSpotClient } from "@/app/lib/hubspot/client";
// -----------------------------------------------------------------------
// Field registry
// -----------------------------------------------------------------------
/**
* Access tokens that gate a field. These are a flat union of the portfolio
* role hierarchy ("read" | "write" | "admin" | "creator") plus any
* orthogonal capability tokens (currently just "approver"). The service
* checks the caller against this set, so a field can require either a
* write-or-above role *or* a specific capability.
*/
export type DealPropertyRole =
| "read"
| "write"
| "admin"
| "creator"
| "approver";
/** Roles that satisfy a "write or above" requirement. */
export const WRITE_OR_ABOVE_ROLES: ReadonlyArray<DealPropertyRole> = [
"write",
"admin",
"creator",
];
/**
* Roles allowed to edit fields gated on "approver capability". An approver
* may not have a write role on the portfolio, but the capability is granted
* orthogonally see `portfolio_capabilities` table.
*/
export const APPROVER_ROLES: ReadonlyArray<DealPropertyRole> = ["approver"];
const isoDateSchema = z
.union([z.string(), z.null()])
.transform((v, ctx) => {
if (v === null || v === "") return null;
const d = new Date(v);
if (Number.isNaN(d.getTime())) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid date" });
return z.NEVER;
}
return d;
});
/**
* String-or-null schema empty strings collapse to null so the UI can
* "clear" a free-text field by sending an empty value, mirroring how the
* date schema treats "" as null.
*/
const stringOrNullSchema = z
.union([z.string(), z.null()])
.transform((v) => (v === null || v === "" ? null : v));
type DateColumn = typeof hubspotDealData.pibiOrderDate;
type TextColumn = typeof hubspotDealData.propertyHaltedReason;
type ColumnFor<T> = T extends Date | null
? DateColumn
: T extends string | null
? TextColumn
: never;
interface DealPropertyFieldDef<TParsed = unknown> {
/** Schema used to parse the incoming JSON value. */
schema: ZodTypeAny;
/** Allowed roles. Empty array means "no role can write" (i.e. read-only). */
allowedRoles: ReadonlyArray<DealPropertyRole>;
/** Property name on the HubSpot deal object. */
hubspotProperty: string;
/**
* Drizzle column to update. Typed loosely because we mix date/text/bool
* columns in the same registry; the per-field schema enforces shape.
*/
dbColumn: unknown;
/**
* Optional renderer that turns the parsed value into the string HubSpot
* expects (HubSpot stores dates as epoch-millisecond strings, booleans as
* "true" / "false", text as-is). Defaults to the parsed value coerced via
* String(...).
*/
toHubspot?: (value: TParsed) => string;
}
// -- HubSpot value formatters ------------------------------------------------
const dateToHubspot = (d: Date | null): string =>
d === null ? "" : String(d.getTime());
const stringToHubspot = (s: string | null): string => (s === null ? "" : s);
// -- Registry ----------------------------------------------------------------
// Slice 5 ships PIBI dates only. Halted (#255) and Domna (#256) reuse the
// same registry by adding entries here — no other code path needs to change.
export const DEAL_PROPERTY_FIELDS = {
pibi_order_date: {
schema: isoDateSchema,
allowedRoles: WRITE_OR_ABOVE_ROLES,
hubspotProperty: "pibi_order_date",
dbColumn: hubspotDealData.pibiOrderDate,
toHubspot: dateToHubspot,
} satisfies DealPropertyFieldDef<Date | null>,
pibi_completed_date: {
schema: isoDateSchema,
allowedRoles: WRITE_OR_ABOVE_ROLES,
hubspotProperty: "pibi_completed_date",
dbColumn: hubspotDealData.pibiCompletedDate,
toHubspot: dateToHubspot,
} satisfies DealPropertyFieldDef<Date | null>,
// -- Halted state (issue #255) -------------------------------------------
// Approver capability gates these — write role alone is not sufficient.
property_halted_date: {
schema: isoDateSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "property_halted_date",
dbColumn: hubspotDealData.propertyHaltedDate,
toHubspot: dateToHubspot,
} satisfies DealPropertyFieldDef<Date | null>,
property_halted_reason: {
schema: stringOrNullSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "property_halted_reason",
dbColumn: hubspotDealData.propertyHaltedReason,
toHubspot: stringToHubspot,
} satisfies DealPropertyFieldDef<string | null>,
// -- Domna survey (issue #256) -------------------------------------------
// Approver capability gates these — write role alone is not sufficient.
domna_survey_type: {
schema: stringOrNullSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "domna_survey_type",
dbColumn: hubspotDealData.domnaSurveyType,
toHubspot: stringToHubspot,
} satisfies DealPropertyFieldDef<string | null>,
domna_survey_date: {
schema: isoDateSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "domna_survey_date",
dbColumn: hubspotDealData.domnaSurveyDate,
toHubspot: dateToHubspot,
} satisfies DealPropertyFieldDef<Date | null>,
} as const;
export type DealPropertyFieldKey = keyof typeof DEAL_PROPERTY_FIELDS;
export function isDealPropertyField(
key: string,
): key is DealPropertyFieldKey {
return Object.prototype.hasOwnProperty.call(DEAL_PROPERTY_FIELDS, key);
}
/**
* Check whether the caller is allowed to write `field`, given their
* portfolio role and any orthogonal capabilities (e.g. "approver"). The
* field passes if any one of the caller's tokens is on the field's
* allow-list.
*/
export function roleAllowedForField(
field: DealPropertyFieldKey,
role: string | null | undefined,
capabilities: ReadonlyArray<string> = [],
): boolean {
const allowed = DEAL_PROPERTY_FIELDS[field]
.allowedRoles as ReadonlyArray<string>;
if (role && allowed.includes(role)) return true;
for (const cap of capabilities) {
if (allowed.includes(cap)) return true;
}
return false;
}
// -----------------------------------------------------------------------
// Update orchestration
// -----------------------------------------------------------------------
export type DealPropertyResult =
| { ok: true }
| { ok: false; error: string };
export type DealPropertyUpdateOutcome = {
/** Per-field results keyed by the same field name supplied by the caller. */
results: Record<string, DealPropertyResult>;
/** Overall HubSpot push outcome. `null` if no push attempted. */
hubspotSync: "ok" | "failed" | "skipped";
hubspotError?: string;
};
/**
* Push the validated, whitelisted property bag to HubSpot, retrying on
* `ECONNRESET`. Mirrors the retry pattern already used by
* `syncContractorDocUploadToHubSpot` / `syncMeasureApprovalsToHubSpot`.
*/
export async function pushDealPropertiesToHubSpot(params: {
hubspotDealId: string;
properties: Record<string, string>;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: params.properties,
});
return { ok: true };
} catch (err) {
const isReset =
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ECONNRESET";
if (isReset && attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
continue;
}
const message =
err instanceof Error ? err.message : "HubSpot sync failed";
console.error("[HubSpot] pushDealPropertiesToHubSpot failed", {
dealId: params.hubspotDealId,
attempt,
error: err,
});
return { ok: false, error: message };
}
}
return { ok: false, error: "HubSpot sync failed" };
}
export interface UpdateDealPropertiesInput {
/** HubSpot deal id (the value stored on `hubspot_deal_data.deal_id`). */
dealId: string;
/** Caller-supplied { fieldName: rawValue } map. */
fields: Record<string, unknown>;
/** Role of the authenticated user making the request. */
role: string;
/**
* Orthogonal capability tokens (e.g. `"approver"`). Used by fields whose
* `allowedRoles` list a capability rather than a role tier. Optional so
* existing call sites that only need role-based gating do not have to
* supply it.
*/
capabilities?: ReadonlyArray<string>;
/**
* Hooks injected by the route so the service can stay environment-free
* for unit testing. Defaults are wired in `applyDealPropertyUpdate`.
*/
deps?: {
pushHubspot?: typeof pushDealPropertiesToHubSpot;
updateDb?: (
dealId: string,
values: Record<string, unknown>,
) => Promise<void>;
};
}
async function defaultUpdateDb(
dealId: string,
values: Record<string, unknown>,
): Promise<void> {
if (Object.keys(values).length === 0) return;
await db
.update(hubspotDealData)
.set(values)
.where(eq(hubspotDealData.dealId, dealId));
}
/**
* Validate caller-supplied fields, persist the accepted ones to the DB and
* push the same set to HubSpot. Returns per-field success / error so the
* route can surface partial failures back to the UI.
*/
export async function applyDealPropertyUpdate(
input: UpdateDealPropertiesInput,
): Promise<DealPropertyUpdateOutcome> {
const results: Record<string, DealPropertyResult> = {};
const dbValues: Record<string, unknown> = {};
const hubspotProperties: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(input.fields)) {
if (!isDealPropertyField(key)) {
results[key] = { ok: false, error: "Field not editable" };
continue;
}
if (!roleAllowedForField(key, input.role, input.capabilities)) {
results[key] = { ok: false, error: "Insufficient permissions" };
continue;
}
const def = DEAL_PROPERTY_FIELDS[key];
const parsed = def.schema.safeParse(rawValue);
if (!parsed.success) {
results[key] = {
ok: false,
error: parsed.error.issues[0]?.message ?? "Invalid value",
};
continue;
}
results[key] = { ok: true };
// The Drizzle column type for date columns accepts Date | null.
const columnName = (def.dbColumn as { name?: string }).name;
if (columnName) {
// Drizzle .set() accepts the JS column key (camelCase). Look it up by
// walking the table object so we use the field name on the schema.
const tableKey = findColumnKey(def.dbColumn);
if (tableKey) dbValues[tableKey] = parsed.data;
}
const renderer = def.toHubspot as
| ((value: unknown) => string)
| undefined;
hubspotProperties[def.hubspotProperty] = renderer
? renderer(parsed.data)
: String(parsed.data ?? "");
}
const updateDb = input.deps?.updateDb ?? defaultUpdateDb;
const pushHubspot = input.deps?.pushHubspot ?? pushDealPropertiesToHubSpot;
// No accepted fields → return early, nothing to sync.
if (Object.keys(dbValues).length === 0) {
return { results, hubspotSync: "skipped" };
}
await updateDb(input.dealId, dbValues);
const sync = await pushHubspot({
hubspotDealId: input.dealId,
properties: hubspotProperties,
});
if (sync.ok) return { results, hubspotSync: "ok" };
return { results, hubspotSync: "failed", hubspotError: sync.error };
}
/**
* Look up the JS-side property name on the Drizzle table for a given column
* object so we can index `db.update().set({ [key]: value })`.
*/
function findColumnKey(column: unknown): string | null {
for (const [key, value] of Object.entries(hubspotDealData)) {
if (value === column) return key;
}
return null;
}

View file

@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock the HubSpot client module before importing the helper. The helper
// calls `getHubSpotClient()` lazily inside each retry attempt, so we
// re-create the mock on every test to control behaviour per-test.
const updateMock = vi.fn();
vi.mock("./client", () => ({
getHubSpotClient: () => ({
crm: { deals: { basicApi: { update: updateMock } } },
}),
}));
import { syncMeasuresFieldToHubSpot } from "./dealSync";
describe("syncMeasuresFieldToHubSpot", () => {
beforeEach(() => {
updateMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("joins measure names with a semicolon and pushes to the named property", async () => {
updateMock.mockResolvedValueOnce(undefined);
const result = await syncMeasuresFieldToHubSpot({
hubspotDealId: "deal-1",
propName: "instructed_measures",
measureNames: ["ASHP", "Solar PV", "Loft insulation"],
});
expect(result).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock).toHaveBeenCalledWith("deal-1", {
properties: { instructed_measures: "ASHP;Solar PV;Loft insulation" },
});
});
it("sends an empty string when the list is empty (clear field)", async () => {
updateMock.mockResolvedValueOnce(undefined);
const result = await syncMeasuresFieldToHubSpot({
hubspotDealId: "deal-2",
propName: "instructed_measures",
measureNames: [],
});
expect(result).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalledWith("deal-2", {
properties: { instructed_measures: "" },
});
});
it("retries up to 3 times on ECONNRESET and succeeds on the final attempt", async () => {
vi.useFakeTimers();
const reset1 = Object.assign(new Error("reset"), { code: "ECONNRESET" });
const reset2 = Object.assign(new Error("reset"), { code: "ECONNRESET" });
updateMock
.mockRejectedValueOnce(reset1)
.mockRejectedValueOnce(reset2)
.mockResolvedValueOnce(undefined);
const promise = syncMeasuresFieldToHubSpot({
hubspotDealId: "deal-3",
propName: "instructed_measures",
measureNames: ["EWI"],
});
// Advance timers past the two backoff windows (200ms then 400ms).
await vi.advanceTimersByTimeAsync(200);
await vi.advanceTimersByTimeAsync(400);
const result = await promise;
expect(result).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalledTimes(3);
});
it("returns failure when ECONNRESET persists past the third attempt", async () => {
vi.useFakeTimers();
const reset = Object.assign(new Error("network reset"), {
code: "ECONNRESET",
});
updateMock
.mockRejectedValueOnce(reset)
.mockRejectedValueOnce(reset)
.mockRejectedValueOnce(reset);
const promise = syncMeasuresFieldToHubSpot({
hubspotDealId: "deal-4",
propName: "instructed_measures",
measureNames: ["IWI"],
});
await vi.advanceTimersByTimeAsync(200);
await vi.advanceTimersByTimeAsync(400);
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe("network reset");
}
expect(updateMock).toHaveBeenCalledTimes(3);
});
it("does not retry for non-ECONNRESET errors", async () => {
const boom = new Error("HubSpot 400 — invalid property value");
updateMock.mockRejectedValueOnce(boom);
const result = await syncMeasuresFieldToHubSpot({
hubspotDealId: "deal-5",
propName: "instructed_measures",
measureNames: ["DMevs"],
});
expect(result).toEqual({
ok: false,
error: "HubSpot 400 — invalid property value",
});
expect(updateMock).toHaveBeenCalledTimes(1);
});
it("works for any property name (so PIBI slice 4 can reuse it)", async () => {
updateMock.mockResolvedValueOnce(undefined);
await syncMeasuresFieldToHubSpot({
hubspotDealId: "deal-6",
propName: "pibi_ordered_measures",
measureNames: ["ASHP", "EWI"],
});
expect(updateMock).toHaveBeenCalledWith("deal-6", {
properties: { pibi_ordered_measures: "ASHP;EWI" },
});
});
});

View file

@ -130,6 +130,82 @@ export async function syncContractorDocUploadToHubSpot(params: {
}
}
/**
* Generic helper for pushing a list of measure names to a multi-value
* HubSpot deal property (e.g. `instructed_measures`, `pibi_ordered_measures`).
*
* HubSpot's multi-select / multi-value text fields use `;` as the native
* delimiter see how `proposed_measures` is parsed by `parseMeasures`.
*
* Mirrors the retry-on-`ECONNRESET` pattern in
* `pushDealPropertiesToHubSpot` and the other deal-sync helpers in this
* file. Returns a discriminated `{ ok }` so the caller can decide whether
* to stamp `pushed_at` on the local DB row.
*
* Issue #253 introduces this helper; slice 4 (PIBI selections) reuses it.
*/
export async function syncMeasuresFieldToHubSpot(params: {
hubspotDealId: string;
propName: string;
measureNames: ReadonlyArray<string>;
}): Promise<{ ok: true } | { ok: false; error: string }> {
// HubSpot multi-value text properties expect a `;`-joined string.
// Empty list collapses to "" so the field can be cleared.
const value = params.measureNames.join(";");
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
[params.propName]: value,
},
});
return { ok: true };
} catch (err) {
const isReset =
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ECONNRESET";
if (isReset && attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
continue;
}
const message =
err instanceof Error ? err.message : "HubSpot sync failed";
console.error("[HubSpot] syncMeasuresFieldToHubSpot failed", {
dealId: params.hubspotDealId,
propName: params.propName,
attempt,
error: err,
});
return { ok: false, error: message };
}
}
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 }>;

View file

@ -0,0 +1,309 @@
/**
* Unit tests for the instruct-measure service.
*
* These tests exercise the orchestration logic DB transaction +
* 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 {
InstructTxOutcome,
RunInstructTx,
ReadInstructedMeasureNames,
StampPushedAt,
SyncMeasuresField,
} from "./instructMeasure";
function makeDeps(overrides?: {
txOutcome?: Partial<InstructTxOutcome>;
txError?: Error;
instructedAfter?: string[];
syncResults?: Array<
{ ok: true } | { ok: false; error: string }
>;
stampError?: Error;
}) {
const txOutcome: InstructTxOutcome = {
instructedRowId: 42n,
existingProposedMeasures: [],
allApprovedMeasureNames: [],
...overrides?.txOutcome,
};
const runInstructTx: RunInstructTx = vi.fn(async () => {
if (overrides?.txError) throw overrides.txError;
return txOutcome;
});
const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn(
async () => overrides?.instructedAfter ?? ["ASHP"],
);
const syncQueue: Array<{ ok: true } | { ok: false; error: string }> =
overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }];
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
return syncQueue.shift() ?? ({ ok: true } as const);
});
const stampPushedAt: StampPushedAt = vi.fn(async () => {
if (overrides?.stampError) throw overrides.stampError;
});
return {
runInstructTx,
readInstructedMeasureNames,
syncMeasuresField,
stampPushedAt,
};
}
describe("instructMeasure — input validation", () => {
it("rejects an unknown measure name without touching the DB or HubSpot", async () => {
const deps = makeDeps();
const result = await instructMeasure({
dealId: "deal-1",
measureName: "Not a real measure",
userId: 1n,
deps,
});
expect(result).toEqual({
ok: false,
error: "Unknown measure: Not a real measure",
});
expect(deps.runInstructTx).not.toHaveBeenCalled();
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
it("rejects an empty measure name", async () => {
const deps = makeDeps();
const result = await instructMeasure({
dealId: "deal-1",
measureName: " ",
userId: 1n,
deps,
});
expect(result.ok).toBe(false);
expect(deps.runInstructTx).not.toHaveBeenCalled();
});
});
describe("instructMeasure — happy path", () => {
it("commits tx, pushes instructed + proposed + approved, stamps pushed_at", async () => {
const deps = makeDeps({
instructedAfter: ["ASHP", "Solar PV"],
txOutcome: {
instructedRowId: 99n,
existingProposedMeasures: ["ASHP"],
allApprovedMeasureNames: ["ASHP", "Solar PV"],
},
});
const result = await instructMeasure({
dealId: "deal-42",
measureName: "Solar PV",
userId: 7n,
deps,
});
expect(result).toMatchObject({
ok: true,
instructedRowId: 99n,
hubspotSync: "ok",
});
expect(deps.runInstructTx).toHaveBeenCalledWith({
dealId: "deal-42",
measureName: "Solar PV",
userId: 7n,
notes: null,
});
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
hubspotDealId: "deal-42",
propName: INSTRUCTED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-42",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, {
hubspotDealId: "deal-42",
propName: APPROVED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.stampPushedAt).toHaveBeenCalledWith(99n);
});
it("merges new measure into existing proposed (deduped)", async () => {
const deps = makeDeps({
instructedAfter: ["ASHP", "EWI"],
txOutcome: {
instructedRowId: 1n,
existingProposedMeasures: ["ASHP", "Solar PV"],
allApprovedMeasureNames: ["ASHP", "EWI"],
},
});
await instructMeasure({
dealId: "deal-merge",
measureName: "EWI",
userId: 3n,
deps,
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-merge",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV", "EWI"],
});
});
it("adds to proposed even when deal already had proposed measures", async () => {
const deps = makeDeps({
instructedAfter: ["EWI"],
txOutcome: {
instructedRowId: 1n,
existingProposedMeasures: ["ASHP", "Solar PV"],
allApprovedMeasureNames: ["EWI"],
},
});
const result = await instructMeasure({
dealId: "deal-with-proposed",
measureName: "EWI",
userId: 3n,
deps,
});
expect(result.ok).toBe(true);
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"],
});
});
});
describe("instructMeasure — DB transaction failure", () => {
it("returns an error and skips HubSpot when the tx throws", async () => {
const deps = makeDeps({ txError: new Error("insert failed") });
const result = await instructMeasure({
dealId: "deal-x",
measureName: "ASHP",
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "insert failed" });
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
expect(deps.readInstructedMeasureNames).not.toHaveBeenCalled();
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
it("returns ok=true with hubspotSync=failed when instructed push fails, does NOT stamp", async () => {
const deps = makeDeps({
instructedAfter: ["ASHP"],
txOutcome: {
instructedRowId: 11n,
existingProposedMeasures: [],
allApprovedMeasureNames: ["ASHP"],
},
syncResults: [
{ ok: false, error: "hubspot 500" },
{ ok: true },
{ ok: true },
],
});
const result = await instructMeasure({
dealId: "deal-h",
measureName: "ASHP",
userId: 1n,
deps,
});
expect(result).toMatchObject({
ok: true,
instructedRowId: 11n,
hubspotSync: "failed",
hubspotError: "hubspot 500",
});
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
it("returns hubspotSync=failed when proposed push fails", async () => {
const deps = makeDeps({
instructedAfter: ["EWI"],
txOutcome: {
instructedRowId: 12n,
existingProposedMeasures: [],
allApprovedMeasureNames: ["EWI"],
},
syncResults: [
{ ok: true },
{ ok: false, error: "proposed push failed" },
{ ok: true },
],
});
const result = await instructMeasure({
dealId: "deal-blank",
measureName: "EWI",
userId: 3n,
deps,
});
expect(result).toMatchObject({
ok: true,
hubspotSync: "failed",
hubspotError: "proposed push failed",
});
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();
});
});

View file

@ -0,0 +1,301 @@
/**
* Instruct-measure service (issue #253)
*
* Implements the headline approver flow: an approver instructs a measure
* that the coordinator did not propose, picking from the canonical
* `MEASURE_NAMES` catalogue. The instruction is recorded locally and
* pushed back to HubSpot.
*
* Single DB transaction:
* 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` list
* 5. read all currently approved measure names for the deal
*
* 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 } from "drizzle-orm";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import {
dealMeasureApprovals,
dealMeasureApprovalEvents,
} from "@/app/db/schema/approvals";
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
import {
MEASURE_NAMES,
type MeasureName,
} from "@/app/lib/measureDocumentRequirements";
import { parseMeasures } from "@/app/lib/parseMeasures";
import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync";
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;
/** 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;
export type RunInstructTx = (params: {
dealId: string;
measureName: MeasureName;
userId: bigint;
notes: string | null;
}) => Promise<InstructTxOutcome>;
export type ReadInstructedMeasureNames = (
dealId: string,
) => Promise<string[]>;
export type StampPushedAt = (rowId: bigint) => Promise<void>;
export type InstructMeasureResult =
| {
ok: true;
instructedRowId: bigint;
hubspotSync: "ok" | "failed";
hubspotError?: string;
}
| { ok: false; error: string };
export interface InstructMeasureInput {
dealId: string;
measureName: string;
userId: bigint;
notes?: string;
/**
* Hooks for the unit tests so the service stays environment-free. The
* route wires the real DB + HubSpot client through.
*/
deps?: {
runInstructTx?: RunInstructTx;
readInstructedMeasureNames?: ReadInstructedMeasureNames;
syncMeasuresField?: SyncMeasuresField;
stampPushedAt?: StampPushedAt;
};
}
function isMeasureName(value: string): value is MeasureName {
return (MEASURE_NAMES as ReadonlyArray<string>).includes(value);
}
// ---------------------------------------------------------------------------
// Default DB-backed implementations of the injectable hooks. The real route
// uses these; tests substitute lightweight in-memory versions.
// ---------------------------------------------------------------------------
const defaultRunInstructTx: RunInstructTx = async ({
dealId,
measureName,
userId,
notes,
}) => {
return await db.transaction(async (tx) => {
const inserted = await tx
.insert(userDefinedDealMeasures)
.values({
hubspotDealId: dealId,
measureName,
source: "instructed",
createdByUserId: userId,
notes,
})
.returning({ id: userDefinedDealMeasures.id });
const instructedRowId = inserted[0]?.id;
if (instructedRowId === undefined || instructedRowId === null) {
throw new Error("Failed to insert user_defined_deal_measures row");
}
// Approval row — keep one row per (deal, measure). The unique
// constraint is on (hubspot_deal_id, measure_name); a duplicate
// instruction for an already-approved measure refreshes the row but
// still produces a new event + a new instructed row.
await tx
.insert(dealMeasureApprovals)
.values({
hubspotDealId: dealId,
measureName,
isApproved: true,
approvedBy: userId,
})
.onConflictDoUpdate({
target: [
dealMeasureApprovals.hubspotDealId,
dealMeasureApprovals.measureName,
],
set: {
isApproved: true,
approvedBy: userId,
approvedAt: new Date(),
},
});
await tx.insert(dealMeasureApprovalEvents).values({
hubspotDealId: dealId,
measureName,
action: "approved",
actedBy: userId,
});
const dealRows = await tx
.select({ proposedMeasures: hubspotDealData.proposedMeasures })
.from(hubspotDealData)
.where(eq(hubspotDealData.dealId, dealId))
.limit(1);
const existingProposedMeasures = parseMeasures(dealRows[0]?.proposedMeasures ?? null);
const approvedRows = await tx
.select({ measureName: dealMeasureApprovals.measureName })
.from(dealMeasureApprovals)
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
);
const allApprovedMeasureNames = approvedRows.map((r) => r.measureName);
return { instructedRowId, existingProposedMeasures, allApprovedMeasureNames };
});
};
const defaultReadInstructedMeasureNames: ReadInstructedMeasureNames = async (
dealId,
) => {
const rows = await db
.select({ measureName: userDefinedDealMeasures.measureName })
.from(userDefinedDealMeasures)
.where(
and(
eq(userDefinedDealMeasures.hubspotDealId, dealId),
eq(userDefinedDealMeasures.source, "instructed"),
),
);
return rows.map((r) => r.measureName);
};
const defaultStampPushedAt: StampPushedAt = async (rowId) => {
await db
.update(userDefinedDealMeasures)
.set({ pushedAt: new Date() })
.where(eq(userDefinedDealMeasures.id, rowId));
};
export async function instructMeasure(
input: InstructMeasureInput,
): Promise<InstructMeasureResult> {
const measureName = input.measureName.trim();
if (!measureName) {
return { ok: false, error: "measureName is required" };
}
if (!isMeasureName(measureName)) {
return { ok: false, error: `Unknown measure: ${measureName}` };
}
const runInstructTx = input.deps?.runInstructTx ?? defaultRunInstructTx;
const readInstructed =
input.deps?.readInstructedMeasureNames ??
defaultReadInstructedMeasureNames;
const syncMeasuresField =
input.deps?.syncMeasuresField ?? defaultSyncMeasuresField;
const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt;
// ---------------------------------------------------------------------
// DB transaction. Any throw here rolls everything back — no row is
// created, no approval is logged, no HubSpot push happens.
// ---------------------------------------------------------------------
let txResult: InstructTxOutcome;
try {
txResult = await runInstructTx({
dealId: input.dealId,
measureName,
userId: input.userId,
notes: input.notes ?? null,
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to instruct measure";
console.error("[instructMeasure] transaction failed", {
dealId: input.dealId,
measureName,
error: err,
});
return { ok: false, error: message };
}
// ---------------------------------------------------------------------
// Post-commit: push to HubSpot. Failures here do NOT roll back the DB;
// `pushed_at` simply stays null so a reconcile job can retry.
// ---------------------------------------------------------------------
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,
});
const proposedSync = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: PROPOSED_MEASURES_PROP,
measureNames: mergedProposed,
});
const approvedSync = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: APPROVED_MEASURES_PROP,
measureNames: txResult.allApprovedMeasureNames,
});
const overallOk = instructedSync.ok && proposedSync.ok && approvedSync.ok;
if (overallOk) {
try {
await stampPushedAt(txResult.instructedRowId);
} catch (err) {
console.error("[instructMeasure] failed to stamp pushed_at", {
rowId: String(txResult.instructedRowId),
error: err,
});
}
return {
ok: true,
instructedRowId: txResult.instructedRowId,
hubspotSync: "ok",
};
}
const hubspotError = !instructedSync.ok
? instructedSync.error
: !proposedSync.ok
? proposedSync.error
: !approvedSync.ok
? approvedSync.error
: "HubSpot sync failed";
return {
ok: true,
instructedRowId: txResult.instructedRowId,
hubspotSync: "failed",
hubspotError,
};
}

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
BASE_DOCS,
MEASURE_DOC_REQUIREMENTS,
MEASURE_NAMES,
getRequiredDocs,
} from "./measureDocumentRequirements";
describe("MEASURE_NAMES catalogue", () => {
it("includes every measure keyed in MEASURE_DOC_REQUIREMENTS", () => {
for (const name of Object.keys(MEASURE_DOC_REQUIREMENTS)) {
expect(MEASURE_NAMES).toContain(name);
}
});
it("includes the names called out in the trailing comment as base-only", () => {
const baseOnly = [
"CWI",
"EWI",
"IWI",
"Flat roof",
"RIR",
"UFI",
"HW",
"Windows",
"Ext. doors",
"TRVs",
"Heating controls",
"New boiler",
"HHRSH",
"Battery",
"LEL",
"Listed building",
"Removal 2nd heating",
"Others",
];
for (const name of baseOnly) {
expect(MEASURE_NAMES).toContain(name);
}
});
});
describe("getRequiredDocs", () => {
it("returns the explicit doc list for a known measure", () => {
const docs = getRequiredDocs("ASHP");
// ASHP gets BASE + MCS_EXTRA + commissioning_records
expect(docs).toEqual([
...BASE_DOCS,
"mcs_compliance_certificate",
"commissioning_records",
]);
});
it("returns Solar PV's specific docs (with G98 notification)", () => {
const docs = getRequiredDocs("Solar PV");
expect(docs).toContain("g98_notification");
expect(docs).toContain("mcs_compliance_certificate");
});
it("returns BASE_DOCS for a measure that only requires the baseline", () => {
const docs = getRequiredDocs("CWI");
expect(docs).toEqual([...BASE_DOCS]);
});
it("returns BASE_DOCS for an unknown measure name", () => {
const docs = getRequiredDocs("Definitely Not A Real Measure");
expect(docs).toEqual([...BASE_DOCS]);
});
it("returns a fresh array (mutating the result must not affect BASE_DOCS)", () => {
const docs = getRequiredDocs("Unknown");
docs.push("mutation");
expect(BASE_DOCS).not.toContain("mutation");
});
});

View file

@ -4,6 +4,40 @@
* Used to compute per-measure upload completion and guide contractors in the upload modal.
*/
/**
* Canonical list of measure names recognised by the application.
*
* This is the single source of truth: document-requirement lookups, UI dropdowns
* and validation should reference this tuple rather than maintaining their own
* copies. Names match the values produced by the upstream HubSpot pull pipeline.
*/
export const MEASURE_NAMES = [
"ASHP",
"Solar PV",
"DMevs",
"Loft insulation",
"CWI",
"EWI",
"IWI",
"Flat roof",
"RIR",
"UFI",
"HW",
"Windows",
"Ext. doors",
"TRVs",
"Heating controls",
"New boiler",
"HHRSH",
"Battery",
"LEL",
"Listed building",
"Removal 2nd heating",
"Others",
] as const;
export type MeasureName = (typeof MEASURE_NAMES)[number];
// Required for every measure
const BASE_DOCS = [
"pre_photo",
@ -18,7 +52,9 @@ const BASE_DOCS = [
// MCS-accredited measures require MCS certification in addition to base docs
const MCS_EXTRA = ["mcs_compliance_certificate"] as const;
export const MEASURE_DOC_REQUIREMENTS: Record<string, string[]> = {
export { BASE_DOCS };
export const MEASURE_DOC_REQUIREMENTS: Partial<Record<MeasureName, string[]>> = {
ASHP: [...BASE_DOCS, ...MCS_EXTRA, "commissioning_records"],
"Solar PV": [...BASE_DOCS, ...MCS_EXTRA, "g98_notification"],
DMevs: [
@ -41,10 +77,11 @@ export const MEASURE_DOC_REQUIREMENTS: Record<string, string[]> = {
/**
* Returns the required document types for a given measure name.
* Falls back to BASE_DOCS for any measure not explicitly listed.
* Falls back to BASE_DOCS for any measure not explicitly listed (including
* unknown measures from upstream).
*/
export function getRequiredDocs(measureName: string): string[] {
return MEASURE_DOC_REQUIREMENTS[measureName] ?? [...BASE_DOCS];
return MEASURE_DOC_REQUIREMENTS[measureName as MeasureName] ?? [...BASE_DOCS];
}
/**

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { parseMeasures } from "./parseMeasures";
describe("parseMeasures", () => {
it("returns [] for null", () => {
expect(parseMeasures(null)).toEqual([]);
});
it("returns [] for undefined", () => {
expect(parseMeasures(undefined)).toEqual([]);
});
it("returns [] for empty string", () => {
expect(parseMeasures("")).toEqual([]);
});
it("returns [] for whitespace-only string", () => {
expect(parseMeasures(" ")).toEqual([]);
expect(parseMeasures("\t\n ")).toEqual([]);
});
it("splits semicolon-separated HubSpot output and trims each entry", () => {
expect(parseMeasures("ASHP;Solar PV;Loft insulation")).toEqual([
"ASHP",
"Solar PV",
"Loft insulation",
]);
});
it("trims whitespace around semicolon-separated entries", () => {
expect(parseMeasures(" ASHP ; Solar PV ;Loft insulation ")).toEqual([
"ASHP",
"Solar PV",
"Loft insulation",
]);
});
it("splits legacy comma-separated input and trims", () => {
expect(parseMeasures("ASHP, Solar PV, Loft insulation")).toEqual([
"ASHP",
"Solar PV",
"Loft insulation",
]);
});
it("tolerates a mix of semicolons and commas", () => {
expect(parseMeasures("ASHP; Solar PV, Loft insulation")).toEqual([
"ASHP",
"Solar PV",
"Loft insulation",
]);
});
it("drops empty entries from trailing or duplicated separators", () => {
expect(parseMeasures("ASHP;;Solar PV;")).toEqual(["ASHP", "Solar PV"]);
expect(parseMeasures(",ASHP,,Solar PV,")).toEqual(["ASHP", "Solar PV"]);
});
it("returns a single entry when the input contains no separator", () => {
expect(parseMeasures("ASHP")).toEqual(["ASHP"]);
expect(parseMeasures(" ASHP ")).toEqual(["ASHP"]);
});
});

View file

@ -0,0 +1,20 @@
/**
* Parses a measure-list string from the HubSpot pull pipeline.
*
* HubSpot's `proposed_measures` field is delivered as a semicolon-separated
* list (its native multi-select format). Earlier records were sometimes stored
* as comma-separated strings, and freeform text from contractors may use either
* separator. To keep the UI tolerant we accept both `;` and `,` as delimiters.
*
* Empty / whitespace-only inputs return `[]`. Individual entries are trimmed
* and any blank entries (e.g. from a trailing separator) are dropped.
*/
export function parseMeasures(raw: string | null | undefined): string[] {
if (!raw) return [];
// Tolerant strategy: split on either `;` or `,`. HubSpot's multi-select
// exports use `;` natively; legacy values and ad-hoc text may use `,`.
return raw
.split(/[;,]/)
.map((m) => m.trim())
.filter(Boolean);
}

View file

@ -0,0 +1,194 @@
/**
* Unit tests for the PIBI-selection service (issue #254).
*
* These tests exercise the orchestration logic DB transaction +
* post-commit HubSpot push by injecting lightweight fakes for the DB
* hooks. The tests never touch the real DB or HubSpot client.
*
* Key properties verified:
* - Happy path: rows inserted, HubSpot push called with correct property,
* pushed_at stamped.
* - No approval rows are created or modified.
* - Sync semantics: pushed_at null when HubSpot fails.
* - DB failure: returns ok=false, no HubSpot call.
* - Empty selection: clears all rows and pushes an empty list.
*/
import { describe, expect, it, vi } from "vitest";
import {
PIBI_MEASURES_PROP,
selectPibiMeasures,
} from "./selectPibiMeasures";
import type {
RunPibiTx,
StampPushedAt,
SyncMeasuresField,
} from "./selectPibiMeasures";
function makeDeps(overrides?: {
txResult?: { insertedRowIds: bigint[] };
txError?: Error;
syncResult?: { ok: true } | { ok: false; error: string };
stampError?: Error;
}) {
const txResult = overrides?.txResult ?? { insertedRowIds: [1n, 2n] };
const runPibiTx: RunPibiTx = 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 { runPibiTx, syncMeasuresField, stampPushedAt };
}
describe("selectPibiMeasures — happy path", () => {
it("commits the tx, pushes to HubSpot under the PIBI property, stamps pushed_at", async () => {
const deps = makeDeps({
txResult: { insertedRowIds: [10n, 11n] },
});
const result = await selectPibiMeasures({
dealId: "deal-1",
measureNames: ["ASHP", "Solar PV"],
userId: 5n,
deps,
});
expect(result).toMatchObject({
ok: true,
insertedRowIds: [10n, 11n],
hubspotSync: "ok",
});
expect(deps.runPibiTx).toHaveBeenCalledWith({
dealId: "deal-1",
measureNames: ["ASHP", "Solar PV"],
userId: 5n,
});
// Must push to the PIBI property specifically, not instructed_measures.
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(1);
expect(deps.syncMeasuresField).toHaveBeenCalledWith({
hubspotDealId: "deal-1",
propName: PIBI_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.stampPushedAt).toHaveBeenCalledWith([10n, 11n]);
});
it("handles an empty selection — clears rows and pushes an empty list", async () => {
const deps = makeDeps({
txResult: { insertedRowIds: [] },
});
const result = await selectPibiMeasures({
dealId: "deal-2",
measureNames: [],
userId: 3n,
deps,
});
expect(result).toMatchObject({
ok: true,
insertedRowIds: [],
hubspotSync: "ok",
});
expect(deps.syncMeasuresField).toHaveBeenCalledWith({
hubspotDealId: "deal-2",
propName: PIBI_MEASURES_PROP,
measureNames: [],
});
// Nothing to stamp when no rows were inserted.
expect(deps.stampPushedAt).toHaveBeenCalledWith([]);
});
});
describe("selectPibiMeasures — no approval rows touched", () => {
it("never calls any approval-related function", async () => {
// The deps object only exposes pibi-specific hooks; if the service
// called any approval function it would have to import it separately.
// We simply confirm the service returns ok=true and only the three
// expected hooks were invoked — no approval side-effects possible.
const deps = makeDeps();
const result = await selectPibiMeasures({
dealId: "deal-3",
measureNames: ["EWI"],
userId: 1n,
deps,
});
expect(result.ok).toBe(true);
// Only these three hooks should exist / be called.
expect(Object.keys(deps)).toEqual(["runPibiTx", "syncMeasuresField", "stampPushedAt"]);
});
});
describe("selectPibiMeasures — DB transaction failure", () => {
it("returns ok=false and skips HubSpot when the tx throws", async () => {
const deps = makeDeps({ txError: new Error("insert failed") });
const result = await selectPibiMeasures({
dealId: "deal-x",
measureNames: ["ASHP"],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "insert failed" });
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
describe("selectPibiMeasures — HubSpot push failure leaves pushed_at null", () => {
it("returns ok=true with hubspotSync=failed and does NOT stamp pushed_at", async () => {
const deps = makeDeps({
txResult: { insertedRowIds: [20n] },
syncResult: { ok: false, error: "hubspot 503" },
});
const result = await selectPibiMeasures({
dealId: "deal-h",
measureNames: ["CWI"],
userId: 2n,
deps,
});
expect(result).toMatchObject({
ok: true,
insertedRowIds: [20n],
hubspotSync: "failed",
hubspotError: "hubspot 503",
});
// DB was committed (tx called) but pushed_at NOT stamped.
expect(deps.runPibiTx).toHaveBeenCalledTimes(1);
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
describe("selectPibiMeasures — sync called with correct property name", () => {
it("always passes measures_for_pibi_ordered as propName, never instructed_measures", async () => {
const deps = makeDeps();
await selectPibiMeasures({
dealId: "deal-prop",
measureNames: ["Loft insulation", "CWI"],
userId: 7n,
deps,
});
const callArg = (deps.syncMeasuresField as ReturnType<typeof vi.fn>).mock
.calls[0][0] as { propName: string };
expect(callArg.propName).toBe("measures_for_pibi_ordered");
expect(callArg.propName).not.toBe("instructed_measures");
});
});

View file

@ -0,0 +1,187 @@
/**
* PIBI-selection service (issue #254)
*
* Lets an approver mark which measures on a deal are going for PIBI. The
* selection is recorded locally as `user_defined_deal_measures` rows with
* `source = "pibi_ordered"` and pushed back to HubSpot under the
* `measures_for_pibi_ordered` deal property via `syncMeasuresFieldToHubSpot`.
*
* Semantics deliberately mirror instruct-measure (issue #253):
* - The incoming `measureNames[]` is the NEW desired set, not a delta. The
* service REPLACES all existing `pibi_ordered` rows for the deal, then
* re-inserts one row per selected measure.
* - No `deal_measure_approvals` rows are created or modified.
* - `pushed_at` is stamped on every new row when HubSpot sync succeeds;
* left null on failure so a reconcile job can retry.
* - HubSpot push failures do NOT roll back the DB.
*
* The service accepts injectable deps so it stays environment-free in tests.
*/
import { and, eq } from "drizzle-orm";
import { db } from "@/app/db/db";
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync";
export const PIBI_MEASURES_PROP = "measures_for_pibi_ordered";
// ---------------------------------------------------------------------------
// Injected dependency types — mirrors the pattern in instructMeasure.ts so
// the service is fully testable without touching the real DB or HubSpot.
// ---------------------------------------------------------------------------
export type SyncMeasuresField = typeof defaultSyncMeasuresField;
/**
* Replace the pibi_ordered rows for the deal inside a transaction.
* Returns the IDs of the newly inserted rows (one per selected measure).
*/
export type RunPibiTx = (params: {
dealId: string;
measureNames: string[];
userId: bigint;
}) => Promise<{ insertedRowIds: bigint[] }>;
export type StampPushedAt = (rowIds: bigint[]) => Promise<void>;
// ---------------------------------------------------------------------------
// Public result type
// ---------------------------------------------------------------------------
export type SelectPibiMeasuresResult =
| {
ok: true;
insertedRowIds: bigint[];
hubspotSync: "ok" | "failed";
hubspotError?: string;
}
| { ok: false; error: string };
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
export interface SelectPibiMeasuresInput {
dealId: string;
/** The FULL desired set of PIBI measures (replaces any prior selection). */
measureNames: string[];
userId: bigint;
deps?: {
runPibiTx?: RunPibiTx;
syncMeasuresField?: SyncMeasuresField;
stampPushedAt?: StampPushedAt;
};
}
// ---------------------------------------------------------------------------
// Default DB-backed implementations
// ---------------------------------------------------------------------------
const defaultRunPibiTx: RunPibiTx = async ({ dealId, measureNames, userId }) => {
return await db.transaction(async (tx) => {
// Delete ALL existing pibi_ordered rows for this deal so the new
// selection fully replaces the previous one.
await tx
.delete(userDefinedDealMeasures)
.where(
and(
eq(userDefinedDealMeasures.hubspotDealId, dealId),
eq(userDefinedDealMeasures.source, "pibi_ordered"),
),
);
if (measureNames.length === 0) {
return { insertedRowIds: [] };
}
const inserted = await tx
.insert(userDefinedDealMeasures)
.values(
measureNames.map((measureName) => ({
hubspotDealId: dealId,
measureName,
source: "pibi_ordered" as const,
createdByUserId: userId,
})),
)
.returning({ id: userDefinedDealMeasures.id });
return { insertedRowIds: inserted.map((r) => r.id) };
});
};
const defaultStampPushedAt: StampPushedAt = async (rowIds) => {
if (rowIds.length === 0) return;
for (const rowId of rowIds) {
await db
.update(userDefinedDealMeasures)
.set({ pushedAt: new Date() })
.where(eq(userDefinedDealMeasures.id, rowId));
}
};
// ---------------------------------------------------------------------------
// Service entry-point
// ---------------------------------------------------------------------------
export async function selectPibiMeasures(
input: SelectPibiMeasuresInput,
): Promise<SelectPibiMeasuresResult> {
const runPibiTx = input.deps?.runPibiTx ?? defaultRunPibiTx;
const syncMeasuresField =
input.deps?.syncMeasuresField ?? defaultSyncMeasuresField;
const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt;
// -------------------------------------------------------------------------
// DB transaction. Any throw here rolls everything back — no row is
// touched, no HubSpot push happens.
// -------------------------------------------------------------------------
let txResult: { insertedRowIds: bigint[] };
try {
txResult = await runPibiTx({
dealId: input.dealId,
measureNames: input.measureNames,
userId: input.userId,
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to save PIBI measures";
console.error("[selectPibiMeasures] transaction failed", {
dealId: input.dealId,
error: err,
});
return { ok: false, error: message };
}
// -------------------------------------------------------------------------
// Post-commit: push the new selection list to HubSpot. Failures here do
// NOT roll back the DB; `pushed_at` simply stays null.
// -------------------------------------------------------------------------
const syncResult = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: PIBI_MEASURES_PROP,
measureNames: input.measureNames,
});
if (syncResult.ok) {
try {
await stampPushedAt(txResult.insertedRowIds);
} catch (err) {
console.error("[selectPibiMeasures] 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,
};
}

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { outOfOrderInstructionWarning } from "./softWarnings";
describe("outOfOrderInstructionWarning", () => {
it("returns null when there are no technical-approved measures", () => {
expect(
outOfOrderInstructionWarning({
technicalApprovedMeasuresForInstall: null,
}),
).toBeNull();
expect(
outOfOrderInstructionWarning({
technicalApprovedMeasuresForInstall: "",
}),
).toBeNull();
expect(
outOfOrderInstructionWarning({
technicalApprovedMeasuresForInstall: undefined,
}),
).toBeNull();
});
it("returns a non-empty warning when technical-approved measures exist", () => {
const w = outOfOrderInstructionWarning({
technicalApprovedMeasuresForInstall: "ASHP;Solar PV",
});
expect(typeof w).toBe("string");
expect(w?.toLowerCase()).toContain("technical-approved");
expect(w?.toLowerCase()).toContain("out of");
});
it("treats whitespace-only text as no measures", () => {
expect(
outOfOrderInstructionWarning({
technicalApprovedMeasuresForInstall: " ",
}),
).toBeNull();
});
});

View file

@ -0,0 +1,36 @@
/**
* Soft warnings (issue #253)
*
* Cross-cutting helper for non-blocking, advisory messages surfaced in the
* UI. The first use case is the "out-of-order" warning shown above the
* Instruct-measure form when a deal already has technical-approved
* measures: instructing a new measure at that point is unusual and the
* approver should pause before doing it.
*
* Kept deliberately small and stateless so later slices (e.g. PIBI flow,
* lodgement guards) can reuse it. The shape is `(deal) => string | null`
* so callers can render the warning verbatim or fold it into a list.
*/
import { parseMeasures } from "@/app/lib/parseMeasures";
export interface OutOfOrderInstructionDealLike {
technicalApprovedMeasuresForInstall?: string | null;
}
/**
* Soft warning shown above the Instruct-measure form when the deal has
* already moved into the technical-approved phase. Returns `null` when
* there are no technical-approved measures (i.e. the instruction is in
* order).
*/
export function outOfOrderInstructionWarning(
deal: OutOfOrderInstructionDealLike,
): string | null {
const measures = parseMeasures(deal.technicalApprovedMeasuresForInstall);
if (measures.length === 0) return null;
return (
"This deal already has technical-approved measures for install. " +
"Instructing a new measure now is out of the usual order — confirm " +
"with the coordinator before proceeding."
);
}

View file

@ -23,6 +23,7 @@ import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRigh
import { uploadFileToS3 } from "@/app/utils/s3";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import { parseMeasures } from "@/app/lib/parseMeasures";
// ── Types ─────────────────────────────────────────────────────────────────
@ -164,11 +165,6 @@ function contentTypeFor(ext: string): string {
return "application/octet-stream";
}
function parseMeasures(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw.split(",").map((m) => m.trim()).filter(Boolean);
}
function s3KeyBasename(key: string): string {
return key.split("/").pop() ?? key;
}

View file

@ -22,7 +22,6 @@ import DocumentTable from "./DocumentTable";
import MeasuresTable from "./MeasuresTable";
import type { HubspotDeal } from "./types";
import PropertyDrawer from "./PropertyDrawer";
import PropertyDetailDrawer from "./PropertyDetailDrawer";
import AnalyticsView from "./AnalyticsView";
import type {
LiveTrackerProps,
@ -75,9 +74,6 @@ export default function LiveTracker({
dealname: null,
});
// ── Property detail drawer ───────────────────────────────────────────
const [detailDeal, setDetailDeal] = useState<ClassifiedDeal | null>(null);
const handleOpenTable = (
stage: string,
filteredDeals: ClassifiedDeal[],
@ -230,7 +226,7 @@ export default function LiveTracker({
<PropertyTable
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
onOpenDetail={setDetailDeal}
portfolioId={portfolioId}
docStatusMap={docStatusMap}
removalStatusByDeal={removalStatusByDeal}
/>
@ -307,7 +303,6 @@ export default function LiveTracker({
)}
<MeasuresTable
data={currentProject?.allDeals ?? []}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
portfolioId={portfolioId}
/>
@ -424,15 +419,6 @@ export default function LiveTracker({
}
/>
{/* ── Property detail drawer ─────────────────────────────────────── */}
<PropertyDetailDrawer
deal={detailDeal}
onClose={() => setDetailDeal(null)}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
userEmail={userEmail}
/>
</div>
);
}

View file

@ -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,11 @@ 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 = {
id: string;
@ -31,16 +30,10 @@ type AuditEvent = {
type Props = {
data: ClassifiedDeal[];
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
portfolioId: string;
};
function parseMeasures(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw.split(",").map((m) => m.trim()).filter(Boolean);
}
function ApprovalStatus({
proposed,
approved,
@ -142,32 +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,
}: Props) {
const router = useRouter();
const [search, setSearch] = useState("");
// pendingChanges: dealId -> desired Set<measureName> (the full intended approved set)
const [pendingChanges, setPendingChanges] = useState<
Record<string, Set<string>>
>({});
const [savedApprovals, setSavedApprovals] =
useState<ApprovalsByDeal>(approvalsByDeal);
const [showConfirm, setShowConfirm] = useState(false);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
// Filter to only properties with proposed measures
@ -187,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<Record<string, PendingDiff>>(() => {
const diffs: Record<string, PendingDiff> = {};
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<Record<string, string>>(() => {
const map: Record<string, string> = {};
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);
@ -297,16 +197,9 @@ export default function MeasuresTable({
<span className="text-xs text-gray-400">
{filtered.length} of {dealsWithMeasures.length} properties
</span>
{userCapability.includes("approver") && hasPendingChanges && (
<Button
size="sm"
onClick={() => setShowConfirm(true)}
className="bg-brandblue text-white gap-1.5"
>
<Save className="h-3.5 w-3.5" />
Review changes ({Object.keys(pendingDiffs).length})
</Button>
)}
<span className="text-xs text-gray-400 hidden sm:inline">
· Click a row to open property
</span>
</div>
</div>
@ -333,24 +226,37 @@ export default function MeasuresTable({
<TableBody>
{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 = () => {
router.push(dealPageUrl);
};
const handleRowKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(dealPageUrl);
}
};
return (
<React.Fragment key={deal.dealId}>
<TableRow
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${hasPending ? "bg-amber-50/30" : ""}`}
data-testid="measures-row"
onClick={handleRowClick}
onKeyDown={handleRowKeyDown}
tabIndex={0}
role="button"
className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer"
>
{/* Expand toggle */}
<TableCell className="py-3 pl-3 pr-0 w-6">
<button
onClick={() => toggleRowExpand(deal.dealId)}
onClick={(e) => { e.stopPropagation(); toggleRowExpand(deal.dealId); }}
className="text-gray-400 hover:text-brandblue transition-colors"
aria-label={isExpanded ? "Collapse activity" : "Expand activity"}
>
@ -384,30 +290,11 @@ export default function MeasuresTable({
</span>
</TableCell>
{/* Proposed measures */}
{/* Proposed measures — read-only; click the row to approve in the drawer */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{proposed.map((measure) => {
const isApproved = approvedSet.has(measure);
if (userCapability.includes("approver")) {
return (
<label
key={measure}
className={`flex items-center gap-1.5 cursor-pointer px-2 py-1 rounded-full text-xs border transition-colors ${
isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
<Checkbox
checked={isApproved}
onCheckedChange={() => toggleMeasure(deal.dealId, measure)}
className="h-3 w-3"
/>
{measure}
</label>
);
}
return (
<span
key={measure}
@ -454,16 +341,6 @@ export default function MeasuresTable({
</Table>
</div>
{/* Confirmation dialog */}
<ApprovalConfirmDialog
open={showConfirm}
pendingDiffs={pendingDiffs}
dealNames={dealNames}
onConfirm={() => saveMutation.mutate()}
onCancel={() => setShowConfirm(false)}
isPending={saveMutation.isPending}
/>
</div>
);
}

View file

@ -67,7 +67,7 @@ type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_additio
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDetail?: (deal: ClassifiedDeal) => void;
portfolioId?: string;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
removalStatusByDeal?: RemovalStatusByDeal;
@ -106,7 +106,7 @@ function escapeCell(value: unknown): string {
: str;
}
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) {
export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const [docFilter, setDocFilter] = useState<DocFilter>("all");
@ -157,8 +157,8 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
}, [data, stageFilter, docFilter, docStatusMap, removalFilter, removalStatusByDeal]);
const columns = useMemo(
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal),
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal]
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, portfolioId, removalStatusByDeal),
[onOpenDrawer, showDocuments, docStatusMap, portfolioId, removalStatusByDeal]
);
const table = useReactTable({

View file

@ -2,6 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import Link from "next/link";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
@ -48,7 +49,7 @@ export function createPropertyTableColumns(
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
onOpenDetail?: (deal: ClassifiedDeal) => void,
portfolioId: string = "",
removalStatusByDeal: RemovalStatusByDeal = {},
): ColumnDef<ClassifiedDeal>[] {
const columns: ColumnDef<ClassifiedDeal>[] = [
@ -60,18 +61,22 @@ export function createPropertyTableColumns(
cell: ({ row }) => {
const removalState = row.original.dealId ? removalStatusByDeal[row.original.dealId] : undefined;
const hasPending = removalState === "pending_removal" || removalState === "pending_re_addition";
const href = portfolioId
? `/portfolio/${portfolioId}/your-projects/live/${row.original.dealId}`
: undefined;
return (
<div className="max-w-[220px] flex items-center gap-1.5">
{hasPending && (
<span className="shrink-0 w-2 h-2 rounded-full bg-amber-400" title="Outstanding removal request" />
)}
{onOpenDetail ? (
<button
onClick={() => onOpenDetail(row.original)}
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate transition-colors"
{href ? (
<Link
href={href}
data-testid="property-row-link"
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight truncate transition-colors"
>
{row.original.dealname ?? "—"}
</button>
</Link>
) : (
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}

View file

@ -0,0 +1,562 @@
"use client";
import { useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/app/shadcn_components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import { AlertTriangle, ChevronRight, ChevronDown } from "lucide-react";
import { sapToEpc } from "@/app/utils";
import { parseMeasures } from "@/app/lib/parseMeasures";
import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings";
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState } from "../types";
import { STAGE_COLORS } from "../types";
import {
InfoRow,
StageBadge,
MilestoneTimeline,
formatDate,
MeasureApprovalEditor,
InstructMeasureEditor,
ApprovalLogSection,
PibiDatesEditor,
PibiMeasureSelector,
DomnaEditor,
HaltedEditor,
SurveyRequestSection,
RemovalRequestSection,
SectionHeader,
SECTION_TITLES,
WRITE_ROLES,
} from "../PropertyDetailDrawer";
type Tab = "works" | "pibi" | "survey-admin" | "documents";
const VALID_TABS: Tab[] = ["works", "pibi", "survey-admin", "documents"];
const TAB_LABELS: Record<Tab, string> = {
works: "Works",
pibi: "PIBI",
"survey-admin": "Survey & Admin",
documents: "Documents",
};
interface DealPageProps {
deal: ClassifiedDeal;
portfolioId: string;
userRole: string;
userCapability: PortfolioCapabilityType;
approvedMeasures: string[];
docStatus: DocStatus;
removalState: EffectiveRemovalState;
userEmail: string;
}
export default function DealPage({
deal,
portfolioId,
userRole,
userCapability,
docStatus,
removalState,
}: DealPageProps) {
const searchParams = useSearchParams();
const router = useRouter();
const rawTab = searchParams.get("tab");
const [activeTab, setActiveTab] = useState<Tab>(
VALID_TABS.includes(rawTab as Tab) ? (rawTab as Tab) : "works",
);
const [instructModalOpen, setInstructModalOpen] = useState(false);
const [isLogOpen, setIsLogOpen] = useState(false);
const switchTab = (tab: Tab) => {
setActiveTab(tab);
router.replace(`?tab=${tab}`, { scroll: false });
};
const epcCurrent = sapToEpc(deal.preSapScore != null ? Number(deal.preSapScore) : null);
const epcPotential = sapToEpc(deal.epcSapScorePotential != null ? Number(deal.epcSapScorePotential) : null);
const technicalApprovedMeasures = parseMeasures(
deal.technicalApprovedMeasuresForInstall ?? null,
);
const pibiMeasures = parseMeasures(deal.measuresForPibiOrdered ?? null);
const isApprover = userCapability.includes("approver");
const canWrite = WRITE_ROLES.includes(userRole);
const stageColors = STAGE_COLORS[deal.displayStage] ?? STAGE_COLORS["Unknown Stage"];
return (
<TooltipProvider>
<div className="grid grid-cols-12 gap-6">
{/* ── Left Sidebar: Property Info ─────────────────────────── */}
<aside className="col-span-12 lg:col-span-3 space-y-5">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 space-y-5">
{/* Header info */}
<div>
<h2 className="text-base font-semibold text-gray-900 leading-snug mb-2">
{deal.dealname ?? "Property"}
</h2>
<div className="flex flex-wrap items-center gap-1.5">
<StageBadge stage={deal.displayStage} />
{deal.landlordPropertyId && (
<span className="text-xs font-mono text-gray-400 bg-gray-50 px-2 py-0.5 rounded border border-gray-200">
{deal.landlordPropertyId}
</span>
)}
</div>
</div>
{/* Damp & mould flag */}
{(deal.dampMouldFlag || deal.majorConditionIssuePhotosS3) && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 border border-red-200">
<AlertTriangle className="h-3.5 w-3.5 text-red-500 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-semibold text-red-700">Damp & Mould Flag</p>
{deal.dampMouldFlag && (
<p className="text-xs text-red-600 mt-0.5">{deal.dampMouldFlag}</p>
)}
{deal.majorConditionIssueDescription && (
<p className="text-xs text-red-600 mt-0.5 italic">
{deal.majorConditionIssueDescription}
</p>
)}
</div>
</div>
)}
{/* EPC */}
<div className="space-y-1.5">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">
Energy Performance
</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-black text-brandblue">{epcCurrent}</span>
{epcPotential !== "Unknown" && epcPotential !== epcCurrent && (
<span className="text-sm text-gray-400"> {epcPotential}</span>
)}
</div>
{deal.preSapScore !== null && deal.preSapScore !== undefined && (
<p className="text-xs text-gray-500">SAP: {deal.preSapScore}</p>
)}
</div>
{/* Key details */}
<div className="space-y-0.5 divide-y divide-gray-50">
<InfoRow label="Project" value={deal.projectCode} />
<InfoRow label="Coordinator" value={deal.coordinator} />
<InfoRow label="Designer" value={deal.designer} />
<InfoRow label="Installer" value={deal.installer} />
<InfoRow label="Outcome" value={deal.outcome} />
{deal.outcomeNotes && (
<InfoRow label="Outcome Notes" value={deal.outcomeNotes} />
)}
<InfoRow label="Coordination" value={deal.coordinationStatus} />
<InfoRow label="Design Status" value={deal.designStatus} />
<InfoRow label="Design Type" value={deal.designType} />
<InfoRow
label="Pre-SAP"
value={
deal.preSapScore ? (
<span
className={`font-semibold px-1.5 py-0.5 rounded text-xs ${
Number(deal.preSapScore) < 30
? "text-red-600 bg-red-50"
: Number(deal.preSapScore) < 50
? "text-amber-700 bg-amber-50"
: "text-emerald-700 bg-emerald-50"
}`}
>
{deal.preSapScore}
</span>
) : null
}
/>
</div>
{/* Survey info */}
<div className="space-y-0.5">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-2">
Survey
</p>
<div className="divide-y divide-gray-50">
<InfoRow label="Survey Type" value={deal.surveyType} />
<InfoRow
label="Surveyed"
value={formatDate(deal.surveyedDate)}
/>
<InfoRow
label="Confirmed Date"
value={formatDate(deal.confirmedSurveyDate)}
/>
<InfoRow
label="Confirmed Time"
value={deal.confirmedSurveyTime}
/>
</div>
</div>
{deal.uprn && (
<p className="text-xs text-gray-400 font-mono">UPRN: {deal.uprn}</p>
)}
</div>
</aside>
{/* ── Center: Tabs ─────────────────────────────────────────── */}
<section className="col-span-12 lg:col-span-6 space-y-4">
{/* Tab bar */}
<div className="flex gap-1 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl">
{VALID_TABS.map((tab) => (
<button
key={tab}
data-testid={`deal-page-tab-${tab}`}
aria-selected={activeTab === tab}
onClick={() => switchTab(tab)}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${
activeTab === tab
? "bg-white text-brandblue shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{TAB_LABELS[tab]}
</button>
))}
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
{/* ── Works ── */}
<div
className={`p-5 space-y-6 ${activeTab === "works" ? "block" : "hidden"}`}
>
{/* Measures */}
<div>
<SectionHeader id="measures" label={SECTION_TITLES.measures} />
<div className="space-y-3">
<MeasureApprovalEditor
dealId={deal.dealId}
dealName={deal.dealname}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
isApprover={isApprover}
/>
</div>
<div className="divide-y divide-gray-50 mt-3">
<InfoRow label="Installed" value={deal.actualMeasuresInstalled} />
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
</div>
</div>
{/* Technical approved */}
<div>
<SectionHeader id="technical" label={SECTION_TITLES.technical} />
<div className="divide-y divide-gray-50">
<InfoRow
label="Technical Approved Measures"
value={
technicalApprovedMeasures.length > 0 ? (
<span className="flex flex-wrap gap-1.5">
{technicalApprovedMeasures.map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-emerald-50 border border-emerald-200 text-emerald-700"
>
{m}
</span>
))}
</span>
) : null
}
/>
</div>
</div>
{/* Approval log */}
<div className="border-t border-gray-100 pt-4">
<button
onClick={() => setIsLogOpen((v) => !v)}
className="flex items-center gap-2 w-full text-left group"
>
{isLogOpen ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
)}
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 group-hover:text-brandblue transition-colors">
Approval Log
</h3>
</button>
{isLogOpen && (
<div className="mt-3">
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
</div>
)}
</div>
</div>
{/* ── PIBI ── */}
<div
className={`p-5 space-y-6 ${activeTab === "pibi" ? "block" : "hidden"}`}
>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<div className="space-y-3">
<PibiDatesEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialOrderDate={deal.pibiOrderDate}
initialCompletedDate={deal.pibiCompletedDate}
canEdit={canWrite}
/>
{isApprover ? (
<PibiMeasureSelector
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit
/>
) : (
pibiMeasures.length > 0 && (
<div className="divide-y divide-gray-50">
<InfoRow
label="Measures for PIBI"
value={
<span className="flex flex-wrap gap-1.5">
{pibiMeasures.map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
>
{m}
</span>
))}
</span>
}
/>
</div>
)
)}
</div>
</div>
{/* ── Survey & Admin ── */}
<div
className={`p-5 space-y-6 ${activeTab === "survey-admin" ? "block" : "hidden"}`}
>
<div>
<SectionHeader id="domna" label={SECTION_TITLES.domna} />
<DomnaEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialSurveyType={deal.domnaSurveyType ?? null}
initialSurveyDate={deal.domnaSurveyDate}
canEdit={isApprover}
/>
</div>
<div>
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
<HaltedEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialHaltedDate={deal.propertyHaltedDate}
initialHaltedReason={deal.propertyHaltedReason ?? null}
canEdit={isApprover}
/>
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Survey Request
</h3>
<SurveyRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
/>
</div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Project Removal
</h3>
<RemovalRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
/>
</div>
</div>
{/* ── Documents ── */}
<div
className={`p-5 space-y-4 ${activeTab === "documents" ? "block" : "hidden"}`}
>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400">
Documents
</h3>
{docStatus.hasSurveyDocs || docStatus.hasInstallDocs ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${docStatus.isSurveyComplete ? "bg-emerald-500" : docStatus.hasSurveyDocs ? "bg-amber-400" : "bg-gray-300"}`}
/>
<span className="text-sm text-gray-700">
Survey docs:{" "}
<span className="font-medium">
{docStatus.isSurveyComplete
? "Complete"
: docStatus.hasSurveyDocs
? `${docStatus.presentSurveyTypes.length} uploaded`
: "None"}
</span>
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${docStatus.installStatus === "all" ? "bg-emerald-500" : docStatus.installStatus === "partial" || docStatus.installStatus === "hasDocs" ? "bg-amber-400" : "bg-gray-300"}`}
/>
<span className="text-sm text-gray-700">
Install docs:{" "}
<span className="font-medium capitalize">
{docStatus.installStatus === "none"
? "None"
: docStatus.installStatus === "all"
? "Complete"
: "Partial"}
</span>
</span>
</div>
{docStatus.measureProgress.length > 0 && (
<div className="space-y-2 mt-2">
{docStatus.measureProgress.map((mp) => (
<div
key={mp.measureName}
className="flex items-center justify-between text-xs py-1.5 border-b border-gray-50 last:border-0"
>
<span className="text-gray-700">{mp.measureName}</span>
<span
className={`font-medium ${mp.isComplete ? "text-emerald-600" : "text-amber-600"}`}
>
{mp.uploadedCount}/{mp.requiredCount}
</span>
</div>
))}
</div>
)}
</div>
) : (
<p className="text-sm text-gray-400">No documents uploaded yet.</p>
)}
</div>
</div>
</section>
{/* ── Right Sidebar: Actions ───────────────────────────────── */}
<aside className="col-span-12 lg:col-span-3 space-y-4">
{/* Removal state badge */}
{removalState !== "none" && (
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-xs font-semibold ${
removalState === "removed"
? "bg-red-50 border-red-200 text-red-700"
: "bg-amber-50 border-amber-200 text-amber-700"
}`}
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{removalState === "pending_removal"
? "Pending removal"
: removalState === "pending_re_addition"
? "Pending re-addition"
: "Removed from project"}
</div>
)}
{/* Actions */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4 space-y-2.5">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Actions
</p>
{/* Instruct Measure */}
{isApprover ? (
<button
onClick={() => setInstructModalOpen(true)}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-brandblue text-white text-sm font-semibold hover:bg-brandmidblue transition-colors"
>
<span>Instruct Measure</span>
<span className="text-xs opacity-70"></span>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
disabled
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-100 text-gray-400 text-sm font-semibold cursor-not-allowed"
>
<span>Instruct Measure</span>
<span className="text-xs"></span>
</button>
</TooltipTrigger>
<TooltipContent>Approver permission required</TooltipContent>
</Tooltip>
)}
{/* Request Survey */}
<button
onClick={() => switchTab("survey-admin")}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
>
<span>Request Survey</span>
<span className="text-xs opacity-50"></span>
</button>
{/* Request Removal */}
<button
onClick={() => switchTab("survey-admin")}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
>
<span>Request Removal</span>
<span className="text-xs opacity-50"></span>
</button>
</div>
{/* Timeline */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Project Timeline
</p>
<MilestoneTimeline deal={deal} />
</div>
</aside>
</div>
{/* ── Instruct Measure Modal ─────────────────────────────────── */}
<Dialog open={instructModalOpen} onOpenChange={setInstructModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-base font-semibold text-brandblue">
Instruct Measure
</DialogTitle>
</DialogHeader>
<InstructMeasureEditor
dealId={deal.dealId}
portfolioId={portfolioId}
canEdit={isApprover}
outOfOrderWarning={outOfOrderInstructionWarning(deal)}
/>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}

View file

@ -0,0 +1,366 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect, notFound } from "next/navigation";
import { eq, inArray, and, desc } from "drizzle-orm";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { alias } from "drizzle-orm/pg-core";
import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { user as userTable } from "@/app/db/schema/users";
import { sql } from "drizzle-orm";
import type {
HubspotDeal,
DocStatus,
MeasureDocProgress,
PortfolioCapabilityType,
EffectiveRemovalState,
} from "../types";
import {
EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
SURVEY_ALL_DOC_TYPES,
} from "../types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import { classifyDeals } from "../transforms";
import type { InferSelectModel } from "drizzle-orm";
import DealPage from "./DealPage";
import Link from "next/link";
const coordinatorUser = alias(hubspotUsers, "coordinator_user");
const designerUser = alias(hubspotUsers, "designer_user");
type DealRow = {
deal: InferSelectModel<typeof hubspotDealData>;
coordinator: string | null;
designer: string | null;
};
function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
const d = row.deal;
return {
id: d.id,
dealId: d.dealId,
dealname: d.dealname,
dealstage: d.dealstage,
companyId: d.companyId,
projectCode: d.projectCode,
landlordPropertyId: d.landlordPropertyId,
uprn: d.uprn,
outcome: d.outcome,
outcomeNotes: d.outcomeNotes,
majorConditionIssueDescription: d.majorConditionIssueDescription,
majorConditionIssuePhotos: d.majorConditionIssuePhotos,
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
coordinationStatus: d.coordinationStatus,
designStatus: d.designStatus,
pashubLink: d.pashubLink,
sharepointLink: d.sharepointLink,
dampMouldFlag: d.dampmouldGrowth,
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
preSapScore: d.preSap,
coordinator: row.coordinator,
ioeV1Date: d.mtpCompletionDate,
ioeV2Date: d.mtpReModelCompletionDate,
ioeV3Date: d.ioeV3CompletionDate,
proposedMeasures: d.proposedMeasures,
approvedPackage: d.approvedPackage,
designer: row.designer,
designDate: d.designCompletionDate,
actualMeasuresInstalled: d.actualMeasuresInstalled,
installer: d.installer,
installerHandover: d.installerHandover,
lodgementStatus: d.lodgementStatus,
measuresLodgementDate: d.measuresLodgementDate,
fullLodgementDate: d.lodgementDate,
confirmedSurveyDate: d.confirmedSurveyDate,
confirmedSurveyTime: d.confirmedSurveyTime,
surveyedDate: d.surveyedDate,
designType: d.dealType,
eiScore: d.eiScore,
eiScorePotential: d.eiScorePotential,
epcSapScore: d.epcSapScore,
epcSapScorePotential: d.epcSapScorePotential,
surveyType: d.surveyType,
measuresForPibiOrdered: d.measuresForPibiOrdered,
pibiOrderDate: d.pibiOrderDate,
pibiCompletedDate: d.pibiCompletedDate,
propertyHaltedDate: d.propertyHaltedDate,
propertyHaltedReason: d.propertyHaltedReason,
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
domnaSurveyType: d.domnaSurveyType,
domnaSurveyDate: d.domnaSurveyDate,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};
}
export default async function DealDetailPage(props: {
params: Promise<{ slug: string; dealId: string }>;
}) {
const { slug: portfolioId, dealId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user) {
redirect("/");
}
const link = await db
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
.from(portfolioOrganisation)
.innerJoin(
organisation,
eq(portfolioOrganisation.organisationId, organisation.id),
)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
if (!link.length || !link[0].hubspotCompanyId) {
redirect(`/portfolio/${portfolioId}/your-projects/live`);
}
const companyId = link[0].hubspotCompanyId;
const rawDeals = await db
.select({
deal: hubspotDealData,
coordinator: sql<string | null>`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`,
designer: sql<string | null>`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`,
})
.from(hubspotDealData)
.leftJoin(
coordinatorUser,
eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId),
)
.leftJoin(
designerUser,
eq(hubspotDealData.designer, designerUser.hubspotOwnerId),
)
.where(
and(
eq(hubspotDealData.companyId, companyId),
eq(hubspotDealData.dealId, dealId),
),
)
.limit(1);
if (!rawDeals.length) {
notFound();
}
const hubspotDeal = mapDbRowToHubspotDeal(rawDeals[0]);
const [deal] = classifyDeals([hubspotDeal]);
const userEmail = session.user.email;
let userCapability: PortfolioCapabilityType = [];
let userRole = "read";
if (userEmail) {
const userRow = await db
.select({ id: userTable.id })
.from(userTable)
.where(eq(userTable.email, userEmail))
.limit(1);
if (userRow[0]) {
const [capRows, roleRow] = await Promise.all([
db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
),
db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1),
]);
userCapability = capRows
.map((r) => r.capability)
.filter(
(c): c is "approver" | "contractor" =>
c === "approver" || c === "contractor",
);
userRole = roleRow[0]?.role ?? "read";
}
}
const approvedMeasures: string[] = [];
const approvalRows = await db
.select({ measureName: dealMeasureApprovals.measureName })
.from(dealMeasureApprovals)
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
);
approvedMeasures.push(...approvalRows.map((r) => r.measureName));
let removalState: EffectiveRemovalState = "none";
const removalRows = await db
.select({
type: propertyRemovalRequests.type,
status: propertyRemovalRequests.status,
})
.from(propertyRemovalRequests)
.where(
and(
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
eq(propertyRemovalRequests.hubspotDealId, dealId),
),
)
.orderBy(desc(propertyRemovalRequests.requestedAt))
.limit(1);
if (removalRows[0]) {
const row = removalRows[0];
if (row.status === "pending") {
removalState =
row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
} else if (row.type === "removal" && row.status === "approved") {
removalState = "removed";
} else if (row.type === "re_addition" && row.status === "declined") {
removalState = "removed";
}
}
// Doc status — same two-phase strategy as live tracker
const docFiles: Array<{ fileType: string; measureName: string | null }> = [];
const phase1Rows = await db
.select({
hubsotDealId: uploadedFiles.hubsotDealId,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(eq(uploadedFiles.hubsotDealId, dealId));
for (const row of phase1Rows) {
if (row.fileType !== null) {
docFiles.push({ fileType: row.fileType, measureName: row.measureName });
}
}
if (docFiles.length === 0 && deal.uprn) {
try {
const uprnBig = BigInt(deal.uprn);
const phase2Rows = await db
.select({
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(eq(uploadedFiles.uprn, uprnBig));
for (const row of phase2Rows) {
if (row.fileType !== null) {
docFiles.push({
fileType: row.fileType,
measureName: row.measureName,
});
}
}
} catch {
// Invalid UPRN — skip phase 2
}
}
const measures =
approvedMeasures.length > 0
? approvedMeasures
: (deal.proposedMeasures ?? "")
.split(",")
.map((m: string) => m.trim())
.filter(Boolean);
const surveyDocs = docFiles.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
const installDocs = docFiles.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter(
(d) => d.measureName === measureName,
);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
return {
measureName,
required,
uploaded,
isComplete: uploaded.length === required.length,
uploadedCount: uploaded.length,
requiredCount: required.length,
};
});
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
installStatus = measureProgress.every((m) => m.isComplete)
? "all"
: measureProgress.some((m) => m.uploadedCount > 0)
? "partial"
: "none";
}
}
const docStatus: DocStatus = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) =>
surveyTypeSet.has(t),
),
hasInstallDocs: installDocs.length > 0,
installStatus,
measureProgress,
};
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
<div className="mb-6">
<nav className="flex items-center gap-1.5 text-sm text-gray-500 mb-3">
<Link
href={`/portfolio/${portfolioId}/your-projects/live`}
className="hover:text-brandblue transition-colors"
>
Live Projects
</Link>
<span className="text-gray-300">/</span>
<span className="text-gray-800 font-medium truncate max-w-xs">
{deal.dealname ?? dealId}
</span>
</nav>
<div className="h-px bg-gray-200" />
</div>
<DealPage
deal={deal}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
approvedMeasures={approvedMeasures}
docStatus={docStatus}
removalState={removalState}
userEmail={userEmail ?? ""}
/>
</div>
);
}

View file

@ -1,11 +1,13 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect } from "next/navigation";
import { eq, inArray, and, desc } from "drizzle-orm";
import { eq, inArray, and, desc, sql } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { alias } from "drizzle-orm/pg-core";
import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
@ -20,53 +22,72 @@ import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
type DbDeal = InferSelectModel<typeof hubspotDealData>;
const coordinatorUser = alias(hubspotUsers, "coordinator_user");
const designerUser = alias(hubspotUsers, "designer_user");
function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
type DealRow = {
deal: InferSelectModel<typeof hubspotDealData>;
coordinator: string | null;
designer: string | null;
};
function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
const d = row.deal;
return {
id: row.id,
dealId: row.dealId,
dealname: row.dealname,
dealstage: row.dealstage,
companyId: row.companyId,
projectCode: row.projectCode,
landlordPropertyId: row.landlordPropertyId,
uprn: row.uprn,
outcome: row.outcome,
outcomeNotes: row.outcomeNotes,
majorConditionIssueDescription: row.majorConditionIssueDescription,
majorConditionIssuePhotos: row.majorConditionIssuePhotos,
majorConditionIssuePhotosS3: row.majorConditionIssuePhotosS3,
coordinationStatus: row.coordinationStatus,
designStatus: row.designStatus,
pashubLink: row.pashubLink,
sharepointLink: row.sharepointLink,
dampMouldFlag: row.dampmouldGrowth,
dampMouldAndRepairComments: row.damnpMouldAndRepairComments,
preSapScore: row.preSap,
id: d.id,
dealId: d.dealId,
dealname: d.dealname,
dealstage: d.dealstage,
companyId: d.companyId,
projectCode: d.projectCode,
landlordPropertyId: d.landlordPropertyId,
uprn: d.uprn,
outcome: d.outcome,
outcomeNotes: d.outcomeNotes,
majorConditionIssueDescription: d.majorConditionIssueDescription,
majorConditionIssuePhotos: d.majorConditionIssuePhotos,
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
coordinationStatus: d.coordinationStatus,
designStatus: d.designStatus,
pashubLink: d.pashubLink,
sharepointLink: d.sharepointLink,
dampMouldFlag: d.dampmouldGrowth,
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
preSapScore: d.preSap,
coordinator: row.coordinator,
ioeV1Date: row.mtpCompletionDate,
ioeV2Date: row.mtpReModelCompletionDate,
ioeV3Date: row.ioeV3CompletionDate,
proposedMeasures: row.proposedMeasures,
approvedPackage: row.approvedPackage,
ioeV1Date: d.mtpCompletionDate,
ioeV2Date: d.mtpReModelCompletionDate,
ioeV3Date: d.ioeV3CompletionDate,
proposedMeasures: d.proposedMeasures,
approvedPackage: d.approvedPackage,
designer: row.designer,
designDate: row.designCompletionDate,
actualMeasuresInstalled: row.actualMeasuresInstalled,
installer: row.installer,
installerHandover: row.installerHandover,
lodgementStatus: row.lodgementStatus,
measuresLodgementDate: row.measuresLodgementDate,
fullLodgementDate: row.lodgementDate,
confirmedSurveyDate: row.confirmedSurveyDate,
surveyedDate: row.surveyedDate,
designType: row.dealType,
eiScore: row.eiScore,
eiScorePotential: row.eiScorePotential,
epcSapScore: row.epcSapScore,
epcSapScorePotential: row.epcSapScorePotential,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
designDate: d.designCompletionDate,
actualMeasuresInstalled: d.actualMeasuresInstalled,
installer: d.installer,
installerHandover: d.installerHandover,
lodgementStatus: d.lodgementStatus,
measuresLodgementDate: d.measuresLodgementDate,
fullLodgementDate: d.lodgementDate,
confirmedSurveyDate: d.confirmedSurveyDate,
confirmedSurveyTime: d.confirmedSurveyTime,
surveyedDate: d.surveyedDate,
designType: d.dealType,
eiScore: d.eiScore,
eiScorePotential: d.eiScorePotential,
epcSapScore: d.epcSapScore,
epcSapScorePotential: d.epcSapScorePotential,
// New per-deal workflow fields
surveyType: d.surveyType,
measuresForPibiOrdered: d.measuresForPibiOrdered,
pibiOrderDate: d.pibiOrderDate,
pibiCompletedDate: d.pibiCompletedDate,
propertyHaltedDate: d.propertyHaltedDate,
propertyHaltedReason: d.propertyHaltedReason,
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
domnaSurveyType: d.domnaSurveyType,
domnaSurveyDate: d.domnaSurveyDate,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};
}
@ -123,8 +144,14 @@ export default async function LiveReportingPage(props: {
const companyId = link[0].hubspotCompanyId;
const rawDeals = await db
.select()
.select({
deal: hubspotDealData,
coordinator: sql<string | null>`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`,
designer: sql<string | null>`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`,
})
.from(hubspotDealData)
.leftJoin(coordinatorUser, eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId))
.leftJoin(designerUser, eq(hubspotDealData.designer, designerUser.hubspotOwnerId))
.where(eq(hubspotDealData.companyId, companyId));
const deals = rawDeals.map(mapDbRowToHubspotDeal);

View file

@ -45,6 +45,7 @@ export type HubspotDeal = {
measuresLodgementDate: Date | null;
fullLodgementDate: Date | null;
confirmedSurveyDate: Date | null;
confirmedSurveyTime: string | null;
surveyedDate: Date | null;
designType: string | null;
eiScore: string | null;
@ -52,6 +53,17 @@ export type HubspotDeal = {
epcSapScore: string | null;
epcSapScorePotential: string | null;
// ── New per-deal workflow fields (issue #249 slice) ────────────────────
surveyType: string | null;
measuresForPibiOrdered: string | null;
pibiOrderDate: Date | null;
pibiCompletedDate: Date | null;
propertyHaltedDate: Date | null;
propertyHaltedReason: string | null;
technicalApprovedMeasuresForInstall: string | null;
domnaSurveyType: string | null;
domnaSurveyDate: Date | null;
createdAt: Date;
updatedAt: Date;
};

17
vitest.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
// Cypress lives under /cypress and uses its own runner; exclude it so the
// two harnesses do not collide.
exclude: ["node_modules", ".next", "cypress"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});