mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #248 from Hestia-Homes/feature/additional-db-columns
added hubspot user table
This commit is contained in:
commit
a456be3c54
52 changed files with 25758 additions and 340 deletions
11
.github/workflows/nextjs-build.yml
vendored
11
.github/workflows/nextjs-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
102
cypress/e2e/live-tracking/domna-survey.cy.js
Normal file
102
cypress/e2e/live-tracking/domna-survey.cy.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
109
cypress/e2e/live-tracking/halted-state.cy.js
Normal file
109
cypress/e2e/live-tracking/halted-state.cy.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
93
cypress/e2e/live-tracking/instruct-measure.cy.js
Normal file
93
cypress/e2e/live-tracking/instruct-measure.cy.js
Normal 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");
|
||||
});
|
||||
});
|
||||
172
cypress/e2e/live-tracking/measure-approval-drawer.cy.js
Normal file
172
cypress/e2e/live-tracking/measure-approval-drawer.cy.js
Normal 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");
|
||||
});
|
||||
});
|
||||
100
cypress/e2e/live-tracking/pibi-dates.cy.js
Normal file
100
cypress/e2e/live-tracking/pibi-dates.cy.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
162
cypress/e2e/live-tracking/pibi-measures.cy.js
Normal file
162
cypress/e2e/live-tracking/pibi-measures.cy.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
50
cypress/e2e/live-tracking/property-deal-page.cy.js
Normal file
50
cypress/e2e/live-tracking/property-deal-page.cy.js
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
83
cypress/e2e/live-tracking/survey-request.cy.js
Normal file
83
cypress/e2e/live-tracking/survey-request.cy.js
Normal 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");
|
||||
});
|
||||
});
|
||||
91
cypress/e2e/live-tracking/tabbed-drawer.cy.js
Normal file
91
cypress/e2e/live-tracking/tabbed-drawer.cy.js
Normal 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
1297
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
132
src/app/api/portfolio/[portfolioId]/deal-properties/route.ts
Normal file
132
src/app/api/portfolio/[portfolioId]/deal-properties/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
145
src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts
Normal file
145
src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
231
src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts
Normal file
231
src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
167
src/app/api/portfolio/[portfolioId]/survey-requests/route.ts
Normal file
167
src/app/api/portfolio/[portfolioId]/survey-requests/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
7
src/app/db/migrations/0192_colorful_quasimodo.sql
Normal file
7
src/app/db/migrations/0192_colorful_quasimodo.sql
Normal 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
|
||||
);
|
||||
1
src/app/db/migrations/0193_domna_survey_type.sql
Normal file
1
src/app/db/migrations/0193_domna_survey_type.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_type" text;
|
||||
16
src/app/db/migrations/0194_user_defined_deal_measures.sql
Normal file
16
src/app/db/migrations/0194_user_defined_deal_measures.sql
Normal 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");
|
||||
15
src/app/db/migrations/0195_survey_requests.sql
Normal file
15
src/app/db/migrations/0195_survey_requests.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
CREATE TABLE "survey_requests" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"hubspot_deal_id" text NOT NULL,
|
||||
"portfolio_id" bigint NOT NULL,
|
||||
"notes" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"requested_by" bigint NOT NULL,
|
||||
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"fulfilled_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_survey_requests_deal_id" ON "survey_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_survey_requests_portfolio_id" ON "survey_requests" USING btree ("portfolio_id");
|
||||
8746
src/app/db/migrations/meta/0192_snapshot.json
Normal file
8746
src/app/db/migrations/meta/0192_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8746
src/app/db/migrations/meta/0193_snapshot.json
Normal file
8746
src/app/db/migrations/meta/0193_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1345,6 +1345,27 @@
|
|||
"when": 1777560763716,
|
||||
"tag": "0191_soft_ezekiel_stane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 192,
|
||||
"version": "7",
|
||||
"when": 1777576507360,
|
||||
"tag": "0192_colorful_quasimodo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 193,
|
||||
"version": "7",
|
||||
"when": 1777750000000,
|
||||
"tag": "0193_domna_survey_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 194,
|
||||
"version": "7",
|
||||
"when": 1778100000000,
|
||||
"tag": "0194_user_defined_deal_measures",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
13
src/app/db/schema/crm/hubspot_user_table.ts
Normal file
13
src/app/db/schema/crm/hubspot_user_table.ts
Normal 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">;
|
||||
41
src/app/db/schema/survey_requests.ts
Normal file
41
src/app/db/schema/survey_requests.ts
Normal 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;
|
||||
67
src/app/db/schema/user_defined_deal_measures.ts
Normal file
67
src/app/db/schema/user_defined_deal_measures.ts
Normal 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"
|
||||
>;
|
||||
478
src/app/lib/dealPropertyUpdate.test.ts
Normal file
478
src/app/lib/dealPropertyUpdate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
361
src/app/lib/dealPropertyUpdate.ts
Normal file
361
src/app/lib/dealPropertyUpdate.ts
Normal 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;
|
||||
}
|
||||
124
src/app/lib/hubspot/dealSync.test.ts
Normal file
124
src/app/lib/hubspot/dealSync.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }>;
|
||||
|
|
|
|||
309
src/app/lib/instructMeasure.test.ts
Normal file
309
src/app/lib/instructMeasure.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
301
src/app/lib/instructMeasure.ts
Normal file
301
src/app/lib/instructMeasure.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
75
src/app/lib/measureDocumentRequirements.test.ts
Normal file
75
src/app/lib/measureDocumentRequirements.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
63
src/app/lib/parseMeasures.test.ts
Normal file
63
src/app/lib/parseMeasures.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
20
src/app/lib/parseMeasures.ts
Normal file
20
src/app/lib/parseMeasures.ts
Normal 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);
|
||||
}
|
||||
194
src/app/lib/selectPibiMeasures.test.ts
Normal file
194
src/app/lib/selectPibiMeasures.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
187
src/app/lib/selectPibiMeasures.ts
Normal file
187
src/app/lib/selectPibiMeasures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
src/app/lib/softWarnings.test.ts
Normal file
39
src/app/lib/softWarnings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
36
src/app/lib/softWarnings.ts
Normal file
36
src/app/lib/softWarnings.ts
Normal 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."
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 ?? "—"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
17
vitest.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue