Merge remote-tracking branch 'origin' into feature/onbarding_of_addresses

This commit is contained in:
Jun-te Kim 2026-05-06 15:50:35 +00:00
commit 4f43d32309
59 changed files with 35149 additions and 341 deletions

View file

@ -13,7 +13,7 @@ services:
- "6420:6420"
volumes:
- ..:/workspaces/assessment-model
- ~/.gitconfig:/home/vscode/.gitconfig:ro
- ~/.gitconfig:/home/vscode/.gitconfig
# GitHub CLI auth from host (created by `gh auth login`). Used by the
# postCreate skill installer to clone private Hestia-Homes repos.
- ~/.config/gh:/home/vscode/.config/gh:ro

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,74 @@
CREATE TYPE "public"."user_defined_deal_measure_source" AS ENUM('instructed', 'pibi_ordered');--> statement-breakpoint
CREATE TABLE "magic_plan_door" (
"id" bigserial PRIMARY KEY NOT NULL,
"magic_plan_room_id" bigint NOT NULL,
"width_mm" real,
"type" text
);
--> statement-breakpoint
CREATE TABLE "magic_plan_floor" (
"id" bigserial PRIMARY KEY NOT NULL,
"magic_plan_plan_id" bigint NOT NULL,
"level" integer
);
--> statement-breakpoint
CREATE TABLE "magic_plan_plan" (
"id" bigserial PRIMARY KEY NOT NULL,
"name" text,
"address" text,
"postcode" text
);
--> statement-breakpoint
CREATE TABLE "magic_plan_room" (
"id" bigserial PRIMARY KEY NOT NULL,
"magic_plan_floor_id" bigint NOT NULL,
"name" text,
"width_m" real,
"length_m" real,
"area_m2" real
);
--> statement-breakpoint
CREATE TABLE "magic_plan_window" (
"id" bigserial PRIMARY KEY NOT NULL,
"magic_plan_room_id" bigint NOT NULL,
"width_m" real,
"height_m" real,
"area_m2" real,
"opening_type" text
);
--> statement-breakpoint
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
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 "hubspot_deal_data" ADD COLUMN "domna_survey_required" boolean;--> statement-breakpoint
ALTER TABLE "magic_plan_door" ADD CONSTRAINT "magic_plan_door_magic_plan_room_id_magic_plan_room_id_fk" FOREIGN KEY ("magic_plan_room_id") REFERENCES "public"."magic_plan_room"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "magic_plan_floor" ADD CONSTRAINT "magic_plan_floor_magic_plan_plan_id_magic_plan_plan_id_fk" FOREIGN KEY ("magic_plan_plan_id") REFERENCES "public"."magic_plan_plan"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "magic_plan_room" ADD CONSTRAINT "magic_plan_room_magic_plan_floor_id_magic_plan_floor_id_fk" FOREIGN KEY ("magic_plan_floor_id") REFERENCES "public"."magic_plan_floor"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "magic_plan_window" ADD CONSTRAINT "magic_plan_window_magic_plan_room_id_magic_plan_room_id_fk" FOREIGN KEY ("magic_plan_room_id") REFERENCES "public"."magic_plan_room"("id") ON DELETE no action ON UPDATE no action;--> 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
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_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");--> 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");

File diff suppressed because it is too large Load diff

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,34 @@
"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
},
{
"idx": 195,
"version": "7",
"when": 1778078457355,
"tag": "0195_jittery_harry_osborn",
"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,14 @@
import { pgTable, bigserial, bigint, text, real } from "drizzle-orm/pg-core";
import { magicPlanRoom } from "./room";
export const magicPlanDoor = pgTable(
"magic_plan_door",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
roomId: bigint("magic_plan_room_id", { mode: "bigint" })
.notNull()
.references(() => magicPlanRoom.id),
widthMm: real("width_mm"),
type: text("type"),
},
);

View file

@ -0,0 +1,13 @@
import { pgTable, bigserial, bigint, integer } from "drizzle-orm/pg-core";
import { magicPlanPlan } from "./plan";
export const magicPlanFloor = pgTable(
"magic_plan_floor",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
planId: bigint("magic_plan_plan_id", { mode: "bigint" })
.notNull()
.references(() => magicPlanPlan.id),
level: integer("level"),
},
);

View file

@ -0,0 +1,11 @@
import { pgTable, bigserial, text } from "drizzle-orm/pg-core";
export const magicPlanPlan = pgTable(
"magic_plan_plan",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
name: text("name"),
address: text("address"),
postcode: text("postcode"),
},
);

View file

@ -0,0 +1,16 @@
import { pgTable, bigserial, bigint, text, real } from "drizzle-orm/pg-core";
import { magicPlanFloor } from "./floor";
export const magicPlanRoom = pgTable(
"magic_plan_room",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
floorId: bigint("magic_plan_floor_id", { mode: "bigint" })
.notNull()
.references(() => magicPlanFloor.id),
name: text("name"),
widthM: real("width_m"),
lengthM: real("length_m"),
areaM2: real("area_m2"),
},
);

View file

@ -0,0 +1,16 @@
import { pgTable, bigserial, bigint, text, real } from "drizzle-orm/pg-core";
import { magicPlanRoom } from "./room";
export const magicPlanWindow = pgTable(
"magic_plan_window",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
roomId: bigint("magic_plan_room_id", { mode: "bigint" })
.notNull()
.references(() => magicPlanRoom.id),
widthM: real("width_m"),
heightM: real("height_m"),
areaM2: real("area_m2"),
openingType: text("opening_type"),
},
);

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