Merge pull request #273 from Hestia-Homes/main
Some checks failed
Test Suite / unit-tests (push) Has been cancelled

Dev deployment!
This commit is contained in:
KhalimCK 2026-05-18 10:26:06 +01:00 committed by GitHub
commit e67d3443b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
190 changed files with 199319 additions and 1953 deletions

View file

@ -1,54 +1,50 @@
FROM library/python:3.12-bullseye
FROM library/python:3.12-bookworm
ARG USER=vscode
ARG USER_UID=1000
ARG USER_GID=1000
ARG DEBIAN_FRONTEND=noninteractive
# Install system dependencies in a single layer
# Base CLI tooling (sudo, git, ripgrep/fd for editors, etc.).
RUN apt update && apt install -y --no-install-recommends \
sudo jq vim curl\
&& apt autoremove -y \
sudo jq vim curl bash-completion \
ripgrep fd-find git make unzip \
&& rm -rf /var/lib/apt/lists/*
# Create the user and grant sudo privileges
RUN useradd -m -s /bin/bash ${USER} \
# Passwordless-sudo dev user (UID/GID injected from the host via compose).
RUN useradd -m -s /bin/bash -u ${USER_UID} ${USER} \
&& echo "${USER} ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/${USER} \
&& chmod 0440 /etc/sudoers.d/${USER}
# Install Node.js 22 (from NodeSource)
# Node 22 (NodeSource).
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt install -y nodejs \
&& node -v \
&& npm -v
&& apt install -y nodejs
# # Install aws
# RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
# RUN unzip awscliv2.zip
# RUN ./aws/install
# GitHub CLI — used by the postCreate skill installer to authenticate against
# private Hestia-Homes repos via the host's mounted ~/.config/gh.
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt update && apt install -y gh \
&& rm -rf /var/lib/apt/lists/*
# # Install terraform
# RUN apt-get update && sudo apt-get install -y gnupg software-properties-common
# RUN wget -O- https://apt.releases.hashicorp.com/gpg | \
# gpg --dearmor | \
# sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null
# RUN echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
# https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
# tee /etc/apt/sources.list.d/hashicorp.list
# RUN apt update
# RUN apt-get install terraform
# RUN terraform -install-autocomplete
# Download Neovim (latest release tarball from GitHub) and symlink onto PATH.
RUN curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
| tar -xz -C /opt \
&& ln -s /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim
# Install Claude
USER ${USER}
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& export PATH="/home/${USER}/.local/bin:${PATH}" \
&& claude plugin marketplace add JuliusBrussee/caveman \
&& claude plugin install caveman@caveman
ENV PATH="/home/vscode/.local/bin:${PATH}"
# LazyVim starter config (.git stripped so the user owns the files).
RUN git clone https://github.com/LazyVim/starter /home/${USER}/.config/nvim \
&& rm -rf /home/${USER}/.config/nvim/.git
# Download + install Claude Code CLI (installs to ~/.local/bin).
RUN curl -fsSL https://claude.ai/install.sh | bash
ENV PATH="/home/${USER}/.local/bin:${PATH}"
USER root
# Set the working directory
WORKDIR /workspaces/assessment-model
WORKDIR /workspaces/assessment-model

View file

@ -4,13 +4,24 @@
"service": "frontend",
"remoteUser": "vscode",
"workspaceFolder": "/workspaces/assessment-model",
"postStartCommand": "bash .devcontainer/post-install.sh",
"forwardPorts": [3000], # For vscode
"appPort": ["3000:3000"], # For devcontainer shell
// Host preflight: ensure GitHub auth exists before we try to build.
// Either ~/.config/gh (from `gh auth login`) or a GITHUB_TOKEN env var.
"initializeCommand": "test -d \"$HOME/.config/gh\" || test -n \"$GITHUB_TOKEN\" || { echo >&2 'error: no GitHub auth found. Run `gh auth login && gh auth setup-git` on the host, or export GITHUB_TOKEN, then retry.'; exit 1; }",
// Install Domna's curated skill set (pinned to 0.0.5) into this workspace,
// then install npm deps. `gh repo clone` handles private-repo auth using
// the mounted host ~/.config/gh.
"postCreateCommand": "gh repo clone Hestia-Homes/agentic-toolkit /tmp/agentic-toolkit -- --branch 0.0.5 --depth 1 && bash /tmp/agentic-toolkit/setup.sh && npm install",
"forwardPorts": [3000],
"appPort": ["3000:3000"],
"mounts": [
// Optional, just makes getting from Downloads (local env) easier
"source=${localEnv:HOME},target=/workspaces/home,type=bind"
],
"customizations": {
"vscode": {
"settings": {

View file

@ -3,6 +3,7 @@ services:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
# Match host UID/GID so files written in the container aren't root-owned.
args:
USER_UID: ${UID:-1000}
USER_GID: ${GID:-1000}
@ -11,8 +12,18 @@ services:
- "3000:3000"
volumes:
- ..:/workspaces/assessment-model
- ~/.gitconfig:/home/vscode/.gitconfig
# GitHub CLI auth from host (created by `gh auth login`). Used by the
# postCreate skill installer to clone private Hestia-Homes repos.
- ~/.config/gh:/home/vscode/.config/gh:ro
environment:
# Host SSH agent — for `git push` etc. inside the container.
- SSH_AUTH_SOCK=${SSH_AUTH_SOCK:-}
# Fallback HTTPS auth if ~/.config/gh isn't present on the host.
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
networks:
- frontend-net
- shared-dev
pgadmin:
image: dpage/pgadmin4
@ -28,3 +39,6 @@ services:
networks:
frontend-net:
driver: bridge
shared-dev:
external: true
name: shared-dev

View file

@ -1 +0,0 @@
npm install;

View file

@ -1,11 +1,12 @@
name: Next.js Build Check
name: Test Suite
on:
push:
branches:
- "**" # all branches
- "**"
jobs:
build:
unit-tests:
runs-on: ubuntu-latest
steps:
@ -21,5 +22,5 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build Next.js app
run: npm run build
- name: Run unit tests
run: npm test

4
.gitignore vendored
View file

@ -37,3 +37,7 @@ cypress.env.json
# typescript
*.tsbuildinfo
next-env.d.ts
backlog/**
docs/adr/**

16
CLAUDE.md Normal file
View file

@ -0,0 +1,16 @@
# Claude guidance for this project
## Project conventions
- **Domain language lives in [`CONTEXT.md`](./CONTEXT.md).** Read it before naming or discussing BulkUpload, Portfolio, Property, etc. concepts.
- **Architectural decisions live in [`docs/adr/`](./docs/adr/).** Read existing ADRs before proposing changes that touch state machines or core flows. Write a new ADR for any decision that's hard to reverse, surprising without context, and the result of a real trade-off.
## React
- **Avoid `useEffect` and `useMemo`.** Derive values inline, prefer Server Components + Route Handlers, prefer event handlers. If a hook is genuinely the only option, flag it and ask before using it.
- **Use TanStack Query (`@tanstack/react-query`), not raw `fetch`, for client-side HTTP.** Reads use `useQuery`; writes use `useMutation`. This project is on **v4** — note that `refetchInterval`'s callback signature is `(data, query)`, not v5's `(query)`.
## Next.js 15 route handlers
- `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring.
- Error responses use `{ error: string }` consistently. Don't drift to `{ msg }` or other shapes.

73
CONTEXT.md Normal file
View file

@ -0,0 +1,73 @@
# Context
This document captures the domain language used in this project. Terms here are the **canonical** ones — when more than one word exists for a concept, we pick one and treat the others as aliases to avoid.
This file grows as terms are resolved during design conversations. Concepts that haven't been examined yet are not listed.
## Language
### Bulk upload
**BulkUpload**:
A user-supplied spreadsheet of addresses for a Portfolio, transformed and matched to UPRNs before being inserted as Properties. Has an explicit lifecycle from upload through finalisation.
_Avoid_: import, batch, file upload, ingest
**ColumnMapping**:
The user's declaration of which spreadsheet column means what (e.g. column "Property Address" means `address_1`). Stored as JSON on the BulkUpload row.
_Avoid_: schema, header map, field mapping
**UPRN**:
Unique Property Reference Number — the UK national identifier for an address. Address matching attaches a UPRN to each row where possible.
**Address matching**:
The pipeline stage that splits the source file by postcode, looks up UPRNs, and produces matched-address output. Triggered via FastAPI.
_Avoid_: postcode lookup, address resolution, address lookup
**Combiner**:
The pipeline stage that aggregates the per-postcode address-matching outputs into a single combined CSV in S3, ready for review.
_Avoid_: aggregator, merger
**Finalise**:
The terminal action that reads the combiner output, inserts rows as Properties on the Portfolio, and decides whether the BulkUpload needs further review.
_Avoid_: import, commit, ingest
## Lifecycle
A **BulkUpload** moves through these statuses:
```
ready_for_processing
→ mapping_complete (user submits ColumnMapping; Next.js writes)
→ processing (Address matching triggered; Next.js writes)
→ combining (Combiner stage running; FastAPI writes directly)
→ awaiting_review (Combiner output in S3; FastAPI writes directly)
→ complete (Finalise succeeded; Next.js writes)
→ failed (FastAPI reports in-flight failure — schema only, not yet wired)
```
`complete` and `failed` are terminal.
Re-mapping (PATCHing `columnMapping`) is legal only in `ready_for_processing` and `mapping_complete`. Any later state rejects with 409.
**Two writers**: Next.js owns transitions out of `mapping_complete`, into `processing`, and the terminal Finalise outcomes. FastAPI owns `combining` and `awaiting_review` — writing them direct to the DB during the combiner run. The BulkUpload aggregate observes both.
See [ADR-0001](./docs/adr/0001-bulk-upload-state-machine.md) for the deliberate "not yet" decisions baked into this lifecycle.
## Relationships
- A **Portfolio** has many **BulkUploads**.
- A **BulkUpload** produces zero or more **Properties** when finalised.
- A **BulkUpload** has at most one **Task** (the orchestration handle for the FastAPI pipeline run); a Task has many **SubTasks** (one per pipeline stage: address matching, combiner).
## Example dialogue
> **Dev:** "If the **Combiner** finishes but the user hasn't clicked Finalise, what does the user see?"
> **Domain expert:** "The BulkUpload sits in `awaiting_review`. The frontend polls and shows a 'review and confirm' button. Nothing's been written to **Properties** yet."
>
> **Dev:** "And if **Finalise** runs and 30% of rows have no **UPRN**?"
> **Domain expert:** "Those still get imported as **Properties** — just without a UPRN — and the BulkUpload moves to `complete`. Manual cleanup happens later in the property table."
## Flagged ambiguities
- "Upload" is used in the codebase to mean both the file-on-S3 and the BulkUpload row. We standardise on **BulkUpload** for the row; the file is just "the source file."
- "Onboarding" appears in some route paths (`bulk_onboarding_inputs/...`) but isn't part of this glossary — we use **BulkUpload** end-to-end.

View file

@ -0,0 +1,102 @@
/**
* Live Tracking Domna Survey editor (issue #256)
*
* Verifies the approver flow on the Domna section of the property detail
* drawer:
* 1. an approver can set a Domna survey type (free text) and date and
* save them,
* 2. the drawer reflects the saved values immediately (optimistic
* update),
* 3. the values persist across a page reload (i.e. the deal-properties
* endpoint wrote them server-side).
*
* Mirrors `halted-state.cy.js`. Assumes an authenticated approver session
* is reusable by the test harness; the target portfolio + a deal whose
* Domna section is editable by the current user are read from Cypress
* env vars so the spec stays portable.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_DOMNA_DEAL_NAME");
const SURVEY_TYPE = "Standard";
const SURVEY_DATE = "2025-07-15";
describe("Domna survey editor — approver flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerForTargetDeal() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
// Switch to the Measures tab — the easiest way into the drawer.
cy.contains("button, [role=tab]", "Measures").click();
if (TARGET_DEAL_NAME) {
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
} else {
cy.get("[data-testid=measures-row]").first().click();
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
// Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click).
cy.get("[data-testid=drawer-tab-survey-admin]").click();
cy.get("[data-testid=drawer-section-domna]").should("exist");
}
it("lets an approver set domna survey type + date and persists them across reload", () => {
openDrawerForTargetDeal();
// Approver sees editable inputs.
cy.get("[data-testid=domna-survey-type-input]").should("be.visible");
cy.get("[data-testid=domna-survey-date-input]").should("be.visible");
cy.get("[data-testid=domna-survey-type-input]")
.clear()
.type(SURVEY_TYPE);
cy.get("[data-testid=domna-survey-date-input]")
.clear()
.type(SURVEY_DATE);
cy.get("[data-testid=domna-save-button]")
.should("not.be.disabled")
.click();
// Save completes — button label flips back, no error banner.
cy.get("[data-testid=domna-save-button]").should(
"contain.text",
"Save Domna Survey",
);
cy.get("[data-testid=domna-error]").should("not.exist");
// Optimistic update — the inputs already reflect the new values.
cy.get("[data-testid=domna-survey-type-input]").should(
"have.value",
SURVEY_TYPE,
);
cy.get("[data-testid=domna-survey-date-input]").should(
"have.value",
SURVEY_DATE,
);
// Reload the page and reopen the drawer — the persisted values must
// still be there.
cy.reload();
openDrawerForTargetDeal();
cy.get("[data-testid=domna-survey-type-input]").should(
"have.value",
SURVEY_TYPE,
);
cy.get("[data-testid=domna-survey-date-input]").should(
"have.value",
SURVEY_DATE,
);
});
});

View file

@ -0,0 +1,109 @@
/**
* Live Tracking Halted state editor (issue #255)
*
* Verifies the approver flow on the Halted section of the property detail
* drawer:
* 1. an approver can set a halted date + free-text reason and save them,
* 2. the drawer reflects the halted state (badge + persisted values),
* 3. clicking Resume clears the date but keeps the reason as the
* last-set value, both in the input and after a reload.
*
* Mirrors `pibi-dates.cy.js`. Assumes an authenticated approver session
* is reusable by the test harness; the target portfolio + a deal whose
* Halted section is editable by the current user are read from Cypress
* env vars so the spec stays portable.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_HALTED_DEAL_NAME");
const HALTED_DATE = "2025-06-01";
const HALTED_REASON = "Awaiting roof access from landlord";
describe("Halted state editor — approver flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerForTargetDeal() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
// Switch to the Measures tab — the easiest way into the drawer.
cy.contains("button, [role=tab]", "Measures").click();
if (TARGET_DEAL_NAME) {
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
} else {
cy.get("[data-testid=measures-row]").first().click();
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
// Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click).
cy.get("[data-testid=drawer-tab-survey-admin]").click();
cy.get("[data-testid=drawer-section-halted]").should("exist");
}
it("lets an approver halt a property and resume it while preserving the reason", () => {
openDrawerForTargetDeal();
// Approver sees editable inputs.
cy.get("[data-testid=halted-date-input]").should("be.visible");
cy.get("[data-testid=halted-reason-input]").should("be.visible");
// Set halted date + reason.
cy.get("[data-testid=halted-date-input]").clear().type(HALTED_DATE);
cy.get("[data-testid=halted-reason-input]")
.clear()
.type(HALTED_REASON);
cy.get("[data-testid=halted-save-button]")
.should("not.be.disabled")
.click();
// Save completes — button label flips back, no error banner.
cy.get("[data-testid=halted-save-button]").should(
"contain.text",
"Save Halted State",
);
cy.get("[data-testid=halted-error]").should("not.exist");
// Drawer reflects halted state via the status badge + persisted values.
cy.get("[data-testid=halted-status-badge]").should("contain.text", "Halted");
cy.get("[data-testid=halted-date-input]").should("have.value", HALTED_DATE);
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
// Now resume — date clears, reason stays.
cy.get("[data-testid=halted-resume-button]")
.should("be.visible")
.click();
// Once resumed the badge + resume button disappear, but the reason is
// still visible in the textarea.
cy.get("[data-testid=halted-status-badge]").should("not.exist");
cy.get("[data-testid=halted-resume-button]").should("not.exist");
cy.get("[data-testid=halted-date-input]").should("have.value", "");
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
// Reload the page — the cleared date and preserved reason persist
// server-side.
cy.reload();
openDrawerForTargetDeal();
cy.get("[data-testid=halted-date-input]").should("have.value", "");
cy.get("[data-testid=halted-reason-input]").should(
"have.value",
HALTED_REASON,
);
});
});

View file

@ -0,0 +1,93 @@
/**
* Live Tracking Instruct measure flow (issue #253)
*
* Verifies the approver flow for instructing a measure that the
* coordinator did not propose:
* 1. the approver opens the property drawer at the Measures section,
* 2. picks a measure from the canonical catalogue dropdown and submits,
* 3. the drawer reflects the new instructed measure (optimistic chip),
* 4. the POST hits the instructed-measures route which pushes
* `instructed_measures` back to HubSpot,
* 5. the approval log surface shows a row for the new approval.
*
* Mirrors `halted-state.cy.js` / `domna-survey.cy.js`. The spec uses
* `cy.intercept` so the HubSpot push side-effect is observable without a
* real CRM round-trip.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_INSTRUCT_DEAL_NAME");
const INSTRUCT_MEASURE = "Loft insulation";
describe("Instruct measure — approver flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerForTargetDeal() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
// Switch to the Measures tab — the easiest way into the drawer at the
// Measures section.
cy.contains("button, [role=tab]", "Measures").click();
if (TARGET_DEAL_NAME) {
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
} else {
cy.get("[data-testid=measures-row]").first().click();
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
cy.get("[data-testid=drawer-section-measures]").should("exist");
}
it("lets an approver instruct a measure and reflects it in the drawer + approval log", () => {
// Capture the API call so we can assert the payload that would be
// pushed to HubSpot under `instructed_measures`.
cy.intercept(
"POST",
`/api/portfolio/*/instructed-measures`,
).as("instructMeasure");
openDrawerForTargetDeal();
// Approver-only form is visible at the bottom of the Measures section.
cy.get("[data-testid=instruct-measure-select]").should("be.visible");
cy.get("[data-testid=instruct-measure-select]").select(INSTRUCT_MEASURE);
cy.get("[data-testid=instruct-measure-submit]")
.should("not.be.disabled")
.click();
// Wait for the POST to land and assert the body shape that the
// service uses to drive the HubSpot push.
cy.wait("@instructMeasure").then((intercepted) => {
expect(intercepted.request.body).to.deep.include({
measureName: INSTRUCT_MEASURE,
});
// Response from our route signals the HubSpot sync outcome — it is
// either "ok" (mock recorded the push) or "failed" (network error).
// We accept either here so the spec stays portable across envs.
expect(intercepted.response.statusCode).to.be.oneOf([200, 201]);
expect(intercepted.response.body).to.have.property("ok", true);
expect(intercepted.response.body).to.have.property("hubspotSync");
});
// Drawer reflects the instructed measure as an optimistic chip.
cy.get("[data-testid=instructed-measures-list]").should("be.visible");
cy.get("[data-testid=instructed-measure-chip]")
.should("contain.text", INSTRUCT_MEASURE);
// No error banner.
cy.get("[data-testid=instruct-measure-error]").should("not.exist");
// Approval log section reveals the new approval row when expanded.
cy.contains("Approval Log").click();
cy.contains(INSTRUCT_MEASURE).should("exist");
});
});

View file

@ -0,0 +1,172 @@
/**
* Live Tracking Measure approval drawer (Works tab)
*
* Verifies the approver flow for approving/unapproving proposed measures
* directly from the Works tab of the PropertyDetailDrawer:
* 1. The approver opens the Works tab and sees measure chips.
* 2. The approver toggles a measure, clicks "Review & Save".
* 3. The ApprovalConfirmDialog appears the user types "approve".
* 4. POST fires to /api/portfolio/*/approvals with the correct payload.
*
* Mirrors the same structure as `pibi-measures.cy.js`.
* Uses `cy.intercept` to observe the API call without a real CRM round-trip.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_APPROVAL_DEAL_NAME");
describe("Measure approval drawer — Works tab approver flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerAtWorksTab() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
// Open a property row from the Measures table to get the detail drawer.
cy.contains("button, [role=tab]", "Measures").click();
if (TARGET_DEAL_NAME) {
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
} else {
cy.get("[data-testid=measures-row]").first().click();
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
// The drawer opens on the Works tab from a Measures row click — verify.
cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible");
cy.get("[data-testid=drawer-section-measures]").should("exist");
}
it("fetches approved measures and shows chips in the Works tab for approvers", () => {
// Stub the GET so we control the initial state.
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
body: {
pibiMeasures: [],
approvedMeasures: ["ASHP", "Solar PV"],
instructedMeasures: [],
},
}).as("getMeasures");
openDrawerAtWorksTab();
cy.wait("@getMeasures");
// Chip container should be visible.
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
// ASHP and Solar PV should be shown as approved (checked).
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked");
cy.get("[data-testid='measure-approval-checkbox-Solar PV']").should(
"be.checked",
);
});
it("lets an approver toggle a measure and POST the approval change", () => {
// Stub GET to return ASHP as already approved.
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
body: {
pibiMeasures: [],
approvedMeasures: ["ASHP"],
instructedMeasures: [],
},
}).as("getMeasures");
// Intercept the POST so we can assert the payload.
cy.intercept("POST", `/api/portfolio/*/approvals`, {
body: { success: true },
}).as("postApprovals");
openDrawerAtWorksTab();
cy.wait("@getMeasures");
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
// ASHP should start approved.
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked");
// Toggle ASHP off (unapprove it).
cy.get("[data-testid=measure-approval-chip-ASHP]").click();
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should(
"not.be.checked",
);
// "Review & Save" button should now be active.
cy.get("[data-testid=measure-approval-save]").should("not.be.disabled");
cy.get("[data-testid=measure-approval-save]").click();
// ApprovalConfirmDialog should be visible — type the confirm word.
cy.contains("Confirm approval changes").should("be.visible");
cy.get("input[placeholder*=\"approve\"]").type("approve");
cy.contains("button", "Confirm").click();
// Wait for the POST and assert the payload.
cy.wait("@postApprovals").then((intercepted) => {
expect(intercepted.request.body).to.have.property("changes");
const changes = intercepted.request.body.changes;
// Should contain one change: ASHP unapproved.
const ashpChange = changes.find((c) => c.measureName === "ASHP");
expect(ashpChange).to.exist;
expect(ashpChange.approved).to.equal(false);
});
// No error banner visible.
cy.get("[data-testid=measure-approval-error]").should("not.exist");
});
it("lets an approver approve a new measure and POST with correct payload", () => {
// Stub GET — no measures approved yet.
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
body: {
pibiMeasures: [],
approvedMeasures: [],
instructedMeasures: [],
},
}).as("getMeasures");
// Intercept POST.
cy.intercept("POST", `/api/portfolio/*/approvals`, {
body: { success: true },
}).as("postApprovals");
openDrawerAtWorksTab();
cy.wait("@getMeasures");
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
// Click the first chip to approve it.
cy.get("[data-testid=measure-approval-chips]")
.find("label")
.first()
.click();
// Save button should be active.
cy.get("[data-testid=measure-approval-save]").should("not.be.disabled");
cy.get("[data-testid=measure-approval-save]").click();
// Confirm dialog — type the word.
cy.contains("Confirm approval changes").should("be.visible");
cy.get("input[placeholder*=\"approve\"]").type("approve");
cy.contains("button", "Confirm").click();
cy.wait("@postApprovals").then((intercepted) => {
expect(intercepted.request.body).to.have.property("changes");
const changes = intercepted.request.body.changes;
// Should have at least one approval.
expect(changes.length).to.be.greaterThan(0);
expect(changes[0]).to.have.property("approved", true);
expect(changes[0]).to.have.property("hubspotDealId");
});
// No error banner.
cy.get("[data-testid=measure-approval-error]").should("not.exist");
});
});

View file

@ -0,0 +1,286 @@
/**
* Live Tracking PibiSection (flat TanStack Table redesign)
*
* Tests the approver flow for the per-measure PIBI request log rendered
* directly on the DealPage (pibi-surveys tab), not in a drawer.
*
* 1. Empty state renders with a "Log first PIBI" prompt
* 2. Flat table shows existing rows (no batch grouping)
* 3. Every row has always-editable cells (measure select, date inputs)
* 4. Save button is disabled when row is clean, enabled after editing
* 5. Save calls PATCH for existing rows
* 6. Delete calls DELETE
* 7. "+ Add row" appends a blank row; save calls POST
* 8. "Mark all complete" PATCHes all incomplete rows
* 9. Scope badges appear for approved / proposed measures
*
* Requires LIVE_PORTFOLIO_SLUG; skipped otherwise.
* All network calls are intercepted no real DB / HubSpot round-trips.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const PORTFOLIO_ID_GLOB = "*";
function stubGet(pibiRequests = []) {
cy.intercept(
"GET",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests*`,
{ body: { pibiRequests } },
).as("getPibiRequests");
}
function stubMeasures(
approvedMeasures = ["ASHP", "CWI"],
instructedMeasures = [],
) {
cy.intercept(
"GET",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-measures*`,
{ body: { pibiMeasures: approvedMeasures, approvedMeasures, instructedMeasures } },
).as("getPibiMeasures");
}
function stubPost(response = { ok: true, insertedCount: 1, hubspotSync: "ok" }) {
cy.intercept(
"POST",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests`,
{ body: response },
).as("postPibiRequest");
}
function stubPatch(id, response = { ok: true, hubspotSync: "ok" }) {
cy.intercept(
"PATCH",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests/${id}`,
{ body: response },
).as(`patchPibiRequest-${id}`);
}
function stubDelete(id, response = { ok: true, hubspotSync: "ok" }) {
cy.intercept(
"DELETE",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests/${id}*`,
{ body: response },
).as(`deletePibiRequest-${id}`);
}
function openDealPageAtPibiTab() {
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-pibi-surveys]").click();
}
describe("PibiSection", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log("LIVE_PORTFOLIO_SLUG not set — skipping PibiSection specs");
this.skip();
}
});
beforeEach(() => {
stubMeasures();
});
// ── Cycle 1: Empty state ──────────────────────────────────────────────────
it("shows empty state with Log first PIBI prompt when no requests exist", () => {
stubGet([]);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-empty-state]").should("be.visible");
cy.get("[data-testid=pibi-empty-state]").should(
"contain.text",
"No PIBIs logged yet",
);
cy.get("[data-testid=pibi-empty-add-row]").should("be.visible");
});
// ── Cycle 2: Flat table renders rows (no batch groups) ────────────────────
it("renders a flat table of rows with no batch grouping", () => {
stubGet([
{
id: "1",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
{
id: "2",
measureName: "CWI",
orderedAt: "2026-04-01T00:00:00.000Z",
completedAt: null,
},
]);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-row-1]").should("be.visible");
cy.get("[data-testid=pibi-row-2]").should("be.visible");
cy.get("[data-testid=pibi-batch-group]").should("not.exist");
});
// ── Cycle 3: Always-editable cells ────────────────────────────────────────
it("renders measure select and date inputs as always-editable cells", () => {
stubGet([
{
id: "5",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-measure-select-5]").should("be.visible");
cy.get("[data-testid=pibi-ordered-date-5]").should("be.visible");
cy.get("[data-testid=pibi-completed-date-5]").should("be.visible");
});
// ── Cycle 4: Save disabled when clean, enabled after editing ──────────────
it("shows Save disabled on load, enabled after editing a cell", () => {
stubGet([
{
id: "6",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-save-6]").should("be.disabled");
cy.get("[data-testid=pibi-ordered-date-6]").clear().type("2026-06-01");
cy.get("[data-testid=pibi-save-6]").should("not.be.disabled");
});
// ── Cycle 5: Save calls PATCH ─────────────────────────────────────────────
it("calls PATCH with updated fields when approver clicks Save", () => {
stubGet([
{
id: "10",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
stubPatch("10");
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-completed-date-10]").type("2026-05-15");
cy.get("[data-testid=pibi-save-10]").click();
cy.wait("@patchPibiRequest-10").then((interception) => {
expect(interception.request.body.dealId).to.be.a("string");
expect(interception.request.body.completedAt).to.include("2026-05-15");
});
});
// ── Cycle 6: Delete calls DELETE ──────────────────────────────────────────
it("calls DELETE when approver clicks Delete on a row", () => {
stubGet([
{
id: "20",
measureName: "EWI",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
stubDelete("20");
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-delete-20]").click();
cy.wait("@deletePibiRequest-20");
});
// ── Cycle 7: Add row → POST ───────────────────────────────────────────────
it("appends a blank row on add-row click and POSTs on save", () => {
stubGet([]);
stubPost({ ok: true, insertedCount: 1, hubspotSync: "ok" });
cy.intercept("GET", `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests*`, {
body: {
pibiRequests: [
{
id: "99",
measureName: "ASHP",
orderedAt: new Date().toISOString(),
completedAt: null,
},
],
},
}).as("getPibiRequestsAfter");
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-empty-add-row]").click();
cy.get("[data-testid=pibi-section]").find("tr[data-testid^=pibi-row-new]").should("have.length", 1);
cy.get("[data-testid=pibi-section]")
.find("[data-testid^=pibi-save-new]")
.click();
cy.wait("@postPibiRequest").then((interception) => {
expect(interception.request.body.measureNames).to.be.an("array").with.length(1);
expect(interception.request.body.orderedAt).to.be.a("string");
});
});
// ── Cycle 8: Mark all complete ────────────────────────────────────────────
it("PATCHes all incomplete rows when Mark all complete is clicked", () => {
stubGet([
{
id: "30",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
{
id: "31",
measureName: "CWI",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
stubPatch("30");
stubPatch("31");
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-mark-all-complete]").click();
cy.wait("@patchPibiRequest-30").then((i) => {
expect(i.request.body.completedAt).to.be.a("string");
});
cy.wait("@patchPibiRequest-31");
});
// ── Cycle 9: Scope badges ─────────────────────────────────────────────────
it("shows Approved badge for a measure in approvedMeasures", () => {
stubGet([
{
id: "40",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
stubMeasures(["ASHP"], []);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-row-40]").should("contain.text", "Approved");
});
});

View file

@ -0,0 +1,50 @@
/**
* Live Tracking Property Deal Page (replaces property-detail-drawer)
*
* Verifies the two core navigation behaviors after moving from a right-side
* drawer to a dedicated CRM-style deal page at /live/[dealId]:
*
* 1. Property table rows link to the correct deal page URL.
* 2. The deal page loads with the Works tab active by default.
*
* Reads LIVE_PORTFOLIO_SLUG from Cypress env so it runs against any seeded
* environment without hard-coding an ID.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
describe("Property deal page", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
it("property table row has link to deal page URL", () => {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
cy.contains("button, [role=tab]", "Properties").click();
cy.get("[data-testid=property-row-link]")
.first()
.should("have.attr", "href")
.and(
"match",
new RegExp(
`/portfolio/${PORTFOLIO_SLUG}/your-projects/live/[^/]+$`,
),
);
});
it("deal page shows Works tab active by default", () => {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
cy.contains("button, [role=tab]", "Properties").click();
cy.get("[data-testid=property-row-link]").first().click();
cy.get("[data-testid=deal-page-tab-works]").should(
"have.attr",
"aria-selected",
"true",
);
});
});

View file

@ -0,0 +1,83 @@
/**
* Live Tracking Survey request flow
*
* Verifies the client-facing "Request Survey" flow in the Survey & Admin tab:
* 1. User opens a property and navigates to Survey & Admin tab.
* 2. User fills in a free-text reason and submits the survey request.
* 3. The POST hits /api/portfolio/[id]/survey-requests.
* 4. The drawer reflects the pending request (badge shown).
* 5. On reload the pending request is still visible.
*
* Uses cy.intercept so the HubSpot side-effect is observable without a live
* CRM round-trip.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_SURVEY_REQUEST_DEAL_NAME");
const SURVEY_NOTES = "Please arrange a full retrofit assessment — tenant has moved in.";
describe("Survey request — write user flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerAtSurveyAdmin() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
cy.contains("button, [role=tab]", "Measures").click();
if (TARGET_DEAL_NAME) {
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
} else {
cy.get("[data-testid=measures-row]").first().click();
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
cy.get("[data-testid=drawer-tab-survey-admin]").click();
cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible");
}
it("shows the survey request form in the Survey & Admin tab", () => {
openDrawerAtSurveyAdmin();
cy.get("[data-testid=survey-request-form]").should("be.visible");
cy.get("[data-testid=survey-request-notes]").should("be.visible");
cy.get("[data-testid=survey-request-submit]").should("be.visible");
});
it("submits a survey request and shows a pending badge", () => {
cy.intercept(
"POST",
`/api/portfolio/*/survey-requests`,
).as("createSurveyRequest");
openDrawerAtSurveyAdmin();
cy.get("[data-testid=survey-request-notes]").type(SURVEY_NOTES);
cy.get("[data-testid=survey-request-submit]")
.should("not.be.disabled")
.click();
cy.wait("@createSurveyRequest").then((intercepted) => {
expect(intercepted.request.body).to.have.property("notes");
expect(intercepted.response.statusCode).to.be.oneOf([200, 201]);
expect(intercepted.response.body).to.have.property("ok", true);
});
// Pending badge appears after submission.
cy.get("[data-testid=survey-request-pending-badge]").should("be.visible");
});
it("persists the pending request across a page reload", () => {
openDrawerAtSurveyAdmin();
// If a pending request exists it should be visible without submitting again.
cy.get("[data-testid=survey-request-pending-badge]").should("be.visible");
});
});

View file

@ -0,0 +1,91 @@
/**
* Live Tracking Tabbed property detail drawer
*
* Verifies the four-tab drawer structure introduced in the UI redesign:
* Overview | Works | PIBI | Survey & Admin
*
* The spec opens the drawer from the Properties table (first row) and asserts
* tab presence, default active state, and navigation between tabs.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
describe("Property detail drawer — tabbed layout", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawer() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
cy.contains("button, [role=tab]", "Measures").click();
cy.get("[data-testid=measures-row]").first().click();
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
}
it("opens with Overview tab active by default", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-overview]")
.should("be.visible")
.and("have.attr", "aria-selected", "true");
cy.get("[data-testid=drawer-tab-panel-overview]").should("be.visible");
});
it("shows all four tabs", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-overview]").should("be.visible");
cy.get("[data-testid=drawer-tab-works]").should("be.visible");
cy.get("[data-testid=drawer-tab-pibi]").should("be.visible");
cy.get("[data-testid=drawer-tab-survey-admin]").should("be.visible");
});
it("navigates to Works tab and shows measures content", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-works]").click();
cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible");
// Measures section lives in Works tab
cy.get("[data-testid=drawer-section-measures]").should("exist");
});
it("navigates to PIBI tab and shows PIBI content", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-pibi]").click();
cy.get("[data-testid=drawer-tab-panel-pibi]").should("be.visible");
cy.get("[data-testid=drawer-section-pibi]").should("exist");
});
it("navigates to Survey & Admin tab and shows admin content", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-survey-admin]").click();
cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible");
cy.get("[data-testid=drawer-section-domna]").should("exist");
});
it("focusSection=pibi opens PIBI tab directly", () => {
// This is exercised by the pibi-dates.cy.js helper that clicks the Measures
// tab row — after the redesign those rows pass focusSection="pibi" and the
// drawer should land on the PIBI tab, not Overview.
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
cy.contains("button, [role=tab]", "Measures").click();
cy.get("[data-testid=measures-row]").first().click();
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
cy.get("[data-testid=drawer-tab-works]")
.should("have.attr", "aria-selected", "true");
});
});

View file

@ -23,8 +23,6 @@ CONFIG_PATH="${REPO_ROOT}/.devcontainer/devcontainer.json"
VALID_COMMANDS=(up shell down rebuild)
# --- helpers ---------------------------------------------------------------
usage() {
sed -n '3,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
@ -36,8 +34,7 @@ die() {
}
in_list() {
local needle="$1"
shift
local needle="$1"; shift
local item
for item in "$@"; do
[[ "${item}" == "${needle}" ]] && return 0
@ -52,10 +49,7 @@ container_id() {
--filter "label=devcontainer.config_file=${CONFIG_PATH}"
}
# --- argument parsing ------------------------------------------------------
[[ $# -eq 1 ]] || usage 1
COMMAND="$1"
in_list "${COMMAND}" "${VALID_COMMANDS[@]}" \
@ -65,8 +59,6 @@ in_list "${COMMAND}" "${VALID_COMMANDS[@]}" \
DC_ARGS=(--workspace-folder "${REPO_ROOT}")
# --- dispatch --------------------------------------------------------------
case "${COMMAND}" in
up)
echo ">> bringing up devcontainer"
@ -74,8 +66,6 @@ case "${COMMAND}" in
;;
shell)
# Auto-up if not already running. `devcontainer up` is idempotent —
# it reuses an existing container, so this is cheap on warm starts.
if [[ -z "$(container_id)" ]]; then
echo ">> devcontainer not running, bringing it up first"
devcontainer up "${DC_ARGS[@]}"

1297
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

89
skills-lock.json Normal file
View file

@ -0,0 +1,89 @@
{
"version": 1,
"skills": {
"caveman": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/caveman/SKILL.md",
"computedHash": "934433479903febc585bf6deb5f0cebc63137e3f86b7babe0aab1ecb94d6d7a4"
},
"diagnose": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/diagnose/SKILL.md",
"computedHash": "15939a26f86edec2d4862042b8564e5a062cb81d04e047a0cea6305c8830b5f5"
},
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea"
},
"grill-with-docs": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/grill-with-docs/SKILL.md",
"computedHash": "31a5b1ae116558bf7d3f633f442835f54bd7645923d4f45c7823e52a97317666"
},
"improve-codebase-architecture": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
"computedHash": "c77b86b4332919499608f9af1880074e1fec65a59b95c70c27a9f39cd137865e"
},
"ralph-loop": {
"source": "Hestia-Homes/agentic-toolkit",
"sourceType": "github",
"skillPath": "skills/engineering/ralph-loop/SKILL.md",
"computedHash": "6d45d44d84abf566d0f298af6b7d710e5f6ebaecb5a06c31fedacd20085ae88d"
},
"setup-matt-pocock-skills": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md",
"computedHash": "3a32f8f1ed8160c9d286a2aabe88ee9b884c6f3f88a7a6c47b7d5d552c959587"
},
"tdd": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/tdd/SKILL.md",
"computedHash": "15a7b5e36383ebadb2dec5e586679e55e9663d292da418926b8da6fc0ef27d84"
},
"to-issues": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/to-issues/SKILL.md",
"computedHash": "73a91f30784523aa59ec9b02769576ebfc738e2cd5ad8f6441076031f0a5d5ac"
},
"to-prd": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/to-prd/SKILL.md",
"computedHash": "fd8c259f9c44eff08e29a1a2fc71a806a3568d279a55387a361f78620b10f2aa"
},
"to-project": {
"source": "Hestia-Homes/agentic-toolkit",
"sourceType": "github",
"skillPath": "skills/engineering/to-project/SKILL.md",
"computedHash": "59daf039ac699a44a9416f8ec403b83d4166e05489959e127746231ff8be4e12"
},
"triage": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/triage/SKILL.md",
"computedHash": "2b6efb6da12d92551772fcc04acf331f4e0e6f7bd9d4cb23ce0b301e0b128feb"
},
"write-a-skill": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/write-a-skill/SKILL.md",
"computedHash": "b44d8aab2ead83c716e01af4c9a24ccc4575ce70ad58ec4f1749fb88c9cc82ba"
},
"zoom-out": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/zoom-out/SKILL.md",
"computedHash": "8357aeaece3b709c442eab67e64b86844e05e2f1ea95b109565eba50b6def36e"
}
}
}

View file

@ -0,0 +1,289 @@
/**
* Unit tests for the approvals POST handler.
*
* Focuses on HubSpot sync behaviour: after approve/unapprove changes are
* persisted to the DB, the handler must push both the audit log
* (client_measures_approval_log) and the structured field (approved_measures)
* to HubSpot. Prior to this fix only the audit log was synced.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks (declared before vi.mock factories run) ─────────────────────
const {
mockGetServerSession,
syncMeasureApprovalsToHubSpotMock,
syncMeasuresFieldToHubSpotMock,
mockDbSelect,
mockDbInsert,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
syncMeasureApprovalsToHubSpotMock: vi.fn(),
syncMeasuresFieldToHubSpotMock: vi.fn(),
mockDbSelect: vi.fn(),
mockDbInsert: vi.fn(),
}));
// ── Auth ──────────────────────────────────────────────────────────────────────
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({
AuthOptions: {},
}));
// ── HubSpot syncs ─────────────────────────────────────────────────────────────
vi.mock("@/app/lib/hubspot/dealSync", () => ({
syncMeasureApprovalsToHubSpot: syncMeasureApprovalsToHubSpotMock,
syncMeasuresFieldToHubSpot: syncMeasuresFieldToHubSpotMock,
}));
vi.mock("@/app/lib/instructMeasure", () => ({
APPROVED_MEASURES_PROP: "approved_measures",
}));
// ── Drizzle ORM ───────────────────────────────────────────────────────────────
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })),
sql: vi.fn(),
}));
// ── DB schema stubs ───────────────────────────────────────────────────────────
vi.mock("@/app/db/schema/approvals", () => ({
dealMeasureApprovals: { hubspotDealId: {}, measureName: {}, isApproved: {}, approvedBy: {}, approvedAt: {} },
dealMeasureApprovalEvents: { hubspotDealId: {}, measureName: {}, action: {}, actedBy: {}, actedAt: {} },
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {}, id: {} },
}));
vi.mock("@/app/db/schema/users", () => ({
user: { id: {}, email: {}, firstName: {} },
}));
// ── DB mock ───────────────────────────────────────────────────────────────────
vi.mock("@/app/db/db", () => ({
db: {
get select() { return mockDbSelect; },
get insert() { return mockDbInsert; },
},
}));
// ── DB mock helpers ────────────────────────────────────────────────────────────
// Builds a thenable select chain where .limit() resolves to `limitResult`
// and awaiting the chain without .limit() resolves to `directResult`.
function makeSelectChain(
limitResult: unknown[],
directResult: unknown[] = [],
) {
const self: Record<string, unknown> = {};
// thenable so `await chain.where(...)` resolves to directResult
self["then"] = (
resolve: (v: unknown) => unknown,
reject: (e: unknown) => unknown,
) => Promise.resolve(directResult).then(resolve, reject);
self["from"] = vi.fn(() => self);
self["leftJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
// Builds an insert chain. .values() is thenable (plain insert) and also
// exposes .onConflictDoUpdate() (upsert).
function makeInsertChain() {
const values: Record<string, unknown> = {};
values["then"] = (
resolve: (v: unknown) => unknown,
reject: (e: unknown) => unknown,
) => Promise.resolve(undefined).then(resolve, reject);
values["onConflictDoUpdate"] = vi.fn(() => Promise.resolve(undefined));
const insert = { values: vi.fn(() => values) };
return insert;
}
// ── Subject under test ────────────────────────────────────────────────────────
import { POST } from "./route";
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeRequest(body: unknown, portfolioId = "10") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/approvals`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
return { req, params: Promise.resolve({ portfolioId }) };
}
function setupHappyPath(approvalRowsAfterChange: Array<{ measureName: string; approvedByEmail: string }>) {
// 1. getServerSession
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
// 2. getUserId select
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([{ id: 1n }]),
);
// 3. hasApproverCapability select
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([{ id: 1n }]),
);
// 4. upsert dealMeasureApprovals (one per change)
mockDbInsert.mockImplementationOnce(() => makeInsertChain());
// 5. insert dealMeasureApprovalEvents (one per change)
mockDbInsert.mockImplementationOnce(() => makeInsertChain());
// 6. post-change approvalRows select (no .limit — awaited at .where())
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([], approvalRowsAfterChange),
);
// HubSpot syncs
syncMeasureApprovalsToHubSpotMock.mockResolvedValue(undefined);
syncMeasuresFieldToHubSpotMock.mockResolvedValue({ ok: true });
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("POST /approvals — approved_measures HubSpot sync", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("syncs approved_measures field to HubSpot after an unapprove action", async () => {
setupHappyPath([
// Only one measure remains approved after the unapprove
{ measureName: "ASHP", approvedByEmail: "approver@test.com" },
]);
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-1", measureName: "Solar PV", approved: false },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(200);
// Allow fire-and-forget promises to settle
await vi.waitFor(() =>
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(),
);
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({
hubspotDealId: "deal-1",
propName: "approved_measures",
measureNames: ["ASHP"],
});
});
it("syncs approved_measures with empty list when all measures removed", async () => {
setupHappyPath([]); // nothing approved after removal
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-2", measureName: "ASHP", approved: false },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(200);
await vi.waitFor(() =>
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(),
);
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({
hubspotDealId: "deal-2",
propName: "approved_measures",
measureNames: [],
});
});
it("syncs approved_measures when a new measure is approved", async () => {
setupHappyPath([
{ measureName: "ASHP", approvedByEmail: "approver@test.com" },
{ measureName: "Solar PV", approvedByEmail: "approver@test.com" },
]);
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-3", measureName: "Solar PV", approved: true },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(200);
await vi.waitFor(() =>
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(),
);
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({
hubspotDealId: "deal-3",
propName: "approved_measures",
measureNames: ["ASHP", "Solar PV"],
});
});
it("also calls the audit-log sync (existing behaviour preserved)", async () => {
setupHappyPath([
{ measureName: "EWI", approvedByEmail: "approver@test.com" },
]);
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-4", measureName: "EWI", approved: true },
],
});
await POST(req, { params });
await vi.waitFor(() =>
expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalled(),
);
expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalledWith(
expect.objectContaining({
hubspotDealId: "deal-4",
approvedMeasures: [{ measureName: "EWI", approvedByEmail: "approver@test.com" }],
}),
);
});
it("does not call HubSpot syncs when session is missing", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-5", measureName: "ASHP", approved: false },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(401);
expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled();
});
it("does not call HubSpot syncs when user lacks approver capability", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "writer@test.com" } });
// getUserId
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n }]));
// hasApproverCapability → empty → no capability
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-6", measureName: "ASHP", approved: false },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(403);
expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled();
});
});

View file

@ -6,11 +6,15 @@ import {
} from "@/app/db/schema/approvals";
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync";
import {
syncMeasureApprovalsToHubSpot,
syncMeasuresFieldToHubSpot,
} from "@/app/lib/hubspot/dealSync";
import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure";
async function getRequestingUserId(email: string): Promise<bigint | null> {
const rows = await db
@ -102,7 +106,7 @@ export async function GET(
.from(dealMeasureApprovalEvents)
.leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy))
.where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds))
.orderBy(dealMeasureApprovalEvents.actedAt);
.orderBy(desc(dealMeasureApprovalEvents.actedAt));
const events = eventRows.map((e) => ({
id: e.id.toString(),
@ -230,6 +234,19 @@ export async function POST(
actedByEmail: session.user.email,
actedAt: now,
});
void syncMeasuresFieldToHubSpot({
hubspotDealId: dealId,
propName: APPROVED_MEASURES_PROP,
measureNames: approvalRows.map((r) => r.measureName),
}).then((result) => {
if (!result.ok) {
console.error("[HubSpot] approved_measures sync failed", {
dealId,
error: result.error,
});
}
});
}
return NextResponse.json({ success: true });

View file

@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq } from "drizzle-orm";
import { bulkApprove } from "@/app/lib/bulkApprove";
const bodySchema = z.object({
changes: z
.array(
z.object({
hubspotDealId: z.string().min(1),
measureName: z.string().min(1),
approved: z.boolean(),
}),
)
.min(1, "changes must not be empty"),
});
/**
* POST /api/portfolio/[portfolioId]/bulk-approvals
*
* Approver-only. Applies all approve/unapprove changes in a single atomic
* DB transaction. If any change fails the entire batch is rolled back.
*
* Body: { changes: [{ hubspotDealId, measureName, approved }] }
* Response: 200 { ok: true, hubspotSync: "ok" | "failed" } | 400/401/403
*/
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
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 capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.some((r) => r.capability === "approver")) {
return NextResponse.json({ error: "Approver capability required" }, { status: 403 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const result = await bulkApprove({
changes: body.changes,
userId: userRow[0].id,
actedByEmail: session.user.email,
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({ ok: true, hubspotSync: result.hubspotSync, hubspotError: result.hubspotError });
}

View file

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
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 { and, eq } from "drizzle-orm";
import { bulkInstructDeals } from "@/app/lib/bulkInstructDeals";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
const bodySchema = z.object({
deals: z
.array(
z.object({
dealId: z.string().min(1),
measureNames: z.array(z.string().min(1)).min(1),
}),
)
.min(1, "deals must not be empty"),
notes: z.string().optional(),
});
/**
* POST /api/portfolio/[portfolioId]/bulk-instructed-measures
*
* Approver-only. Instructs the given measures on each listed deal in a single
* atomic DB transaction. If any deal/measure fails the entire batch rolls back.
*
* Body: { deals: [{ dealId, measureNames }], notes? }
* Response: 200 { ok: true, hubspotSync } | 400/401/403
*/
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
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 capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.some((r) => r.capability === "approver")) {
return NextResponse.json({ error: "Approver capability required" }, { status: 403 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
for (const deal of body.deals) {
for (const name of deal.measureNames) {
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(name)) {
return NextResponse.json({ error: `Unknown measure: ${name}` }, { status: 400 });
}
}
}
const result = await bulkInstructDeals({
deals: body.deals,
userId: userRow[0].id,
notes: body.notes,
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({ ok: true, hubspotSync: result.hubspotSync, hubspotError: result.hubspotError });
}

View file

@ -0,0 +1,35 @@
import { requestCombineRetrigger } from "@/lib/bulkUpload/server";
import { readSessionToken } from "@/lib/session";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const result = await requestCombineRetrigger({
uploadId,
sessionToken: readSessionToken(request),
});
switch (result.kind) {
case "triggered":
return NextResponse.json(
{ taskId: result.taskId, subTaskId: result.subTaskId },
{ status: 200 }
);
case "already_combined":
return NextResponse.json({ alreadyCombined: true }, { status: 200 });
case "not_found":
return NextResponse.json({ error: "Not found" }, { status: 404 });
case "missing_task":
return NextResponse.json({ error: "Upload has no task" }, { status: 422 });
case "trigger_failed":
return NextResponse.json({ error: result.message }, { status: result.status });
}
}

View file

@ -0,0 +1,160 @@
import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { createRetrofitDataS3Client } from "@/app/utils/s3";
import * as XLSX from "xlsx";
import { loadForFinalize, markFinalized } from "@/lib/bulkUpload/server";
const ADDRESS_COLS = ["Address 1", "Address 2", "Address 3"] as const;
const POSTCODE_COL = "postcode";
const INTERNAL_REF_COL = "Internal Reference";
const UPRN_COL = "address2uprn_uprn";
const MATCHED_ADDRESS_COL = "address2uprn_address";
const LEXISCORE_COL = "address2uprn_lexiscore";
const MISSING_SENTINEL = "invalid postcode";
const UK_POSTCODE_RE = /[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i;
function normalize(v: unknown): string {
if (v === null || v === undefined) return "";
return String(v).trim();
}
function isMissing(v: string): boolean {
return v === "" || v.toLowerCase() === MISSING_SENTINEL;
}
function parseUprn(raw: unknown): bigint | null {
const v = normalize(raw);
if (isMissing(v)) return null;
try {
return BigInt(v);
} catch {
return null;
}
}
function parseLexiscore(raw: unknown): number | null {
const v = normalize(raw);
if (isMissing(v)) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function extractPostcode(matched: string | null, fallback: string): string | null {
if (matched) {
const m = matched.match(UK_POSTCODE_RE);
if (m) return m[0].toUpperCase();
}
return fallback || null;
}
function parseS3Uri(uri: string): { bucket: string; key: string } | null {
if (!uri.startsWith("s3://")) return null;
const rest = uri.slice(5);
const slash = rest.indexOf("/");
if (slash < 0) return null;
return { bucket: rest.slice(0, slash), key: rest.slice(slash + 1) };
}
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const guarded = await loadForFinalize(uploadId);
switch (guarded.kind) {
case "not_found":
return NextResponse.json({ error: "Not found" }, { status: 404 });
case "already_finalized":
return new NextResponse(null, { status: 200 });
case "wrong_state":
return NextResponse.json(
{ error: `Upload not ready to finalize (state: ${guarded.current})` },
{ status: 409 }
);
case "not_yet_combined":
return NextResponse.json({ error: "Combiner not finished" }, { status: 409 });
}
const upload = guarded.upload;
const parsed = parseS3Uri(upload.combinedOutputS3Uri!);
if (!parsed) {
return NextResponse.json({ error: "Invalid combined output S3 URI" }, { status: 500 });
}
const s3 = createRetrofitDataS3Client();
let rawRows: Record<string, unknown>[];
try {
const obj = await s3
.getObject({ Bucket: parsed.bucket, Key: parsed.key })
.promise();
const buf = Buffer.from(obj.Body as Uint8Array);
const wb = XLSX.read(buf, { type: "buffer" });
const sheet = wb.Sheets[wb.SheetNames[0]];
rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: "" });
} catch (err) {
console.error("Failed to read combined CSV from S3:", err);
return NextResponse.json({ error: "Failed to read combined CSV" }, { status: 502 });
}
const portfolioIdBig = BigInt(upload.portfolioId);
const values = rawRows.map((raw) => {
const userInputtedAddress =
ADDRESS_COLS.map((c) => normalize(raw[c])).filter(Boolean).join(", ") || null;
const userInputtedPostcode = normalize(raw[POSTCODE_COL]) || null;
const uprn = parseUprn(raw[UPRN_COL]);
const matchedAddressRaw = normalize(raw[MATCHED_ADDRESS_COL]);
const matchedAddress = isMissing(matchedAddressRaw) ? null : matchedAddressRaw;
const address = matchedAddress ?? userInputtedAddress;
const postcode = extractPostcode(matchedAddress, userInputtedPostcode ?? "");
const internalRef = normalize(raw[INTERNAL_REF_COL]) || null;
const lexiscore = parseLexiscore(raw[LEXISCORE_COL]);
return {
portfolioId: portfolioIdBig,
creationStatus: "READY" as const,
uprn,
landlordPropertyId: internalRef,
address,
postcode,
userInputtedAddress,
userInputtedPostcode,
lexiscore,
};
});
try {
if (values.length > 0) {
await db
.insert(property)
.values(values)
.onConflictDoNothing({
target: [property.portfolioId, property.uprn],
where: sql`${property.uprn} IS NOT NULL`,
});
}
await markFinalized(uploadId);
revalidatePath("/portfolio/[slug]", "layout");
return new NextResponse(null, { status: 200 });
} catch (err) {
console.error("Failed to finalize bulk upload:", err);
return NextResponse.json({ error: "Failed to import properties" }, { status: 500 });
}
}

View file

@ -0,0 +1,17 @@
import { getProgressView } from "@/lib/bulkUpload/server";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const view = await getProgressView(uploadId);
if (!view) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(view, { status: 200 });
}

View file

@ -0,0 +1,41 @@
import { setColumnMapping } from "@/lib/bulkUpload/server";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const PatchSchema = z.object({
columnMapping: z.record(z.string(), z.string()),
});
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const { uploadId } = await params;
let body;
try {
body = PatchSchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const result = await setColumnMapping(uploadId, body.columnMapping);
switch (result.kind) {
case "ok":
return NextResponse.json(result.upload, { status: 200 });
case "not_found":
return NextResponse.json({ error: "Not found" }, { status: 404 });
case "invalid_status":
return NextResponse.json(
{ error: `Cannot remap upload in state '${result.current}'` },
{ status: 409 }
);
case "invalid_mapping":
return NextResponse.json({ error: result.reason }, { status: 422 });
}
} catch (error) {
console.error("Failed to save column mapping:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { createS3Client, createRetrofitDataS3Client, retrofitDataS3Bucket } from "@/app/utils/s3";
import * as XLSX from "xlsx";
import { loadForAddressMatching, triggerAddressMatching } from "@/lib/bulkUpload/server";
import { readSessionToken } from "@/lib/session";
const FIELD_RENAME: Record<string, string> = {
address_1: "Address 1",
address_2: "Address 2",
address_3: "Address 3",
postcode: "postcode",
internal_reference: "Internal Reference",
};
function transformFile(
buffer: Buffer,
columnMapping: Record<string, string>
): { csv: string; error?: never } | { csv?: never; error: string } {
const wb = XLSX.read(buffer, { type: "buffer" });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: "" });
if (rows.length === 0) return { error: "Empty file" };
const sourceHeaders = Object.keys(rows[0]);
const outputHeaders: string[] = [];
const sourceToOutput: Record<string, string> = {};
for (const src of sourceHeaders) {
const mapped = columnMapping[src];
if (!mapped || mapped === "skip") continue;
const renamed = FIELD_RENAME[mapped] ?? mapped;
outputHeaders.push(renamed);
sourceToOutput[src] = renamed;
}
if (!outputHeaders.includes("Address 1"))
return { error: 'Mapping must include "Address 1"' };
if (!outputHeaders.includes("postcode"))
return { error: 'Mapping must include "postcode"' };
const outputRows = rows.map((row) => {
const out: Record<string, unknown> = {};
for (const [src, renamed] of Object.entries(sourceToOutput)) {
out[renamed] = row[src] ?? "";
}
return out;
});
const outSheet = XLSX.utils.json_to_sheet(outputRows, { header: outputHeaders });
return { csv: XLSX.utils.sheet_to_csv(outSheet) };
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { portfolioId, uploadId } = await params;
const guarded = await loadForAddressMatching(uploadId);
switch (guarded.kind) {
case "not_found":
return NextResponse.json({ error: "Not found" }, { status: 404 });
case "wrong_state":
return NextResponse.json(
{ error: `Upload not ready for onboarding (state: ${guarded.current})` },
{ status: 409 }
);
case "missing_mapping":
return NextResponse.json({ error: "Column mapping missing" }, { status: 422 });
}
const upload = guarded.upload;
const s3 = createS3Client();
const outputS3 = createRetrofitDataS3Client();
const outputBucket = retrofitDataS3Bucket();
let fileBuffer: Buffer;
try {
const obj = await s3
.getObject({ Bucket: upload.s3Bucket, Key: upload.s3Key })
.promise();
fileBuffer = Buffer.from(obj.Body as Uint8Array);
} catch (err) {
console.error("Failed to read source file from S3:", err);
return NextResponse.json({ error: "Failed to read source file" }, { status: 500 });
}
const transformed = transformFile(fileBuffer, upload.columnMapping!);
if (transformed.error)
return NextResponse.json({ error: transformed.error }, { status: 422 });
const transformedKey = `bulk_onboarding_inputs/${portfolioId}/${uploadId}.csv`;
try {
await outputS3
.putObject({
Bucket: outputBucket,
Key: transformedKey,
Body: transformed.csv,
ContentType: "text/csv",
})
.promise();
} catch (err) {
console.error("Failed to upload transformed CSV:", err);
return NextResponse.json({ error: "Failed to store transformed file" }, { status: 500 });
}
const s3Uri = `s3://${outputBucket}/${transformedKey}`;
const trigger = await triggerAddressMatching({
uploadId,
s3Uri,
sessionToken: readSessionToken(request),
});
if (trigger.kind === "trigger_failed")
return NextResponse.json({ error: trigger.message }, { status: trigger.status });
return NextResponse.json({ taskId: trigger.taskId }, { status: 200 });
}

View file

@ -0,0 +1,17 @@
import { listForPortfolio } from "@/lib/bulkUpload/server";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await params;
try {
const uploads = await listForPortfolio(portfolioId);
return NextResponse.json(uploads, { status: 200 });
} catch (error) {
console.error("Failed to fetch bulk uploads:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

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

View file

@ -0,0 +1,148 @@
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 { instructMeasures } from "@/app/lib/instructMeasure";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
const postSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
measureNames: z
.array(z.string().min(1))
.min(1, "measureNames must not be empty"),
});
/**
* 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, 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;
// Validate all names against the canonical catalogue up-front.
for (const name of measureNames) {
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(name)) {
return NextResponse.json(
{ error: `Unknown measure: ${name}` },
{ 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 instructMeasures({
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("[instructed-measures POST]", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
const {
mockGetServerSession,
mockDbSelect,
mockDbInsert,
mockDbDelete,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockDbSelect: vi.fn(),
mockDbInsert: vi.fn(),
mockDbDelete: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })),
}));
vi.mock("@/app/db/schema/portfolio_organisation", () => ({
portfolioOrganisation: {
portfolioId: {},
organisationId: {},
id: {},
},
}));
vi.mock("@/app/db/schema/organisation", () => ({
organisation: { id: {}, name: {}, hubspotCompanyId: {} },
}));
vi.mock("@/app/db/db", () => ({
db: {
get select() { return mockDbSelect; },
get insert() { return mockDbInsert; },
get delete() { return mockDbDelete; },
},
}));
// ── Chain builders ────────────────────────────────────────────────────────────
function makeSelectChain(rows: unknown[]) {
const self: Record<string, unknown> = {};
self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(rows).then(resolve, reject);
self["from"] = vi.fn(() => self);
self["innerJoin"] = vi.fn(() => self);
self["leftJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(rows));
return self;
}
function makeInsertChain() {
const self: Record<string, unknown> = {};
self["values"] = vi.fn(() => Promise.resolve([]));
return self;
}
function makeDeleteChain() {
const self: Record<string, unknown> = {};
self["where"] = vi.fn(() => Promise.resolve([]));
return self;
}
function makeParams(portfolioId = "42") {
return Promise.resolve({ portfolioId });
}
function makeRequest(method: string, body?: unknown, portfolioId = "42") {
return new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/organisation`,
{
method,
...(body ? { body: JSON.stringify(body), headers: { "content-type": "application/json" } } : {}),
},
);
}
// ── Subject under test ────────────────────────────────────────────────────────
import { GET, POST, DELETE } from "./route";
describe("GET /portfolio/:id/organisation", () => {
beforeEach(() => vi.clearAllMocks());
it("returns empty array when no orgs linked", async () => {
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
const res = await GET(makeRequest("GET"), { params: makeParams() });
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual([]);
});
it("returns all linked orgs as array", async () => {
const orgs = [
{ id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" },
{ id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" },
];
mockDbSelect.mockImplementationOnce(() => makeSelectChain(orgs));
const res = await GET(makeRequest("GET"), { params: makeParams() });
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toHaveLength(2);
expect(json[0].name).toBe("Alpha Housing");
expect(json[1].name).toBe("Beta Council");
});
});
describe("POST /portfolio/:id/organisation", () => {
beforeEach(() => vi.clearAllMocks());
it("returns 403 for non-Domna user", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } });
const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() });
expect(res.status).toBe(403);
});
it("returns 400 when organisationId missing", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
const res = await POST(makeRequest("POST", {}), { params: makeParams() });
expect(res.status).toBe(400);
});
it("returns 409 when org already linked to this portfolio", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
// existing link found
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: "link-1" }]));
const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() });
expect(res.status).toBe(409);
});
it("adds org without removing existing links", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
// no existing link for this org
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
const insertChain = makeInsertChain();
mockDbInsert.mockImplementationOnce(() => insertChain);
// return updated list
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([
{ id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" },
{ id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" },
]),
);
const res = await POST(makeRequest("POST", { organisationId: "org-2" }), { params: makeParams() });
expect(res.status).toBe(200);
// insert called — no delete called
expect(mockDbDelete).not.toHaveBeenCalled();
expect(insertChain.values).toHaveBeenCalled();
const json = await res.json();
expect(json).toHaveLength(2);
});
});
describe("DELETE /portfolio/:id/organisation", () => {
beforeEach(() => vi.clearAllMocks());
it("returns 403 for non-Domna user", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } });
const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() });
expect(res.status).toBe(403);
});
it("removes the specific org link", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
const deleteChain = makeDeleteChain();
mockDbDelete.mockImplementationOnce(() => deleteChain);
const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() });
expect(res.status).toBe(200);
expect(deleteChain.where).toHaveBeenCalled();
const json = await res.json();
expect(json.success).toBe(true);
});
it("returns 400 when organisationId missing", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
const res = await DELETE(makeRequest("DELETE", {}), { params: makeParams() });
expect(res.status).toBe(400);
});
});

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db } from "@/app/db/db";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
@ -10,14 +10,8 @@ function isDomnaUser(email: string | null | undefined): boolean {
return !!email?.endsWith("@domna.homes");
}
// GET — fetch the current linked organisation for this portfolio
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await params;
const rows = await db
function linkedOrgsQuery(portfolioId: string) {
return db
.select({
id: organisation.id,
name: organisation.name,
@ -25,13 +19,20 @@ export async function GET(
})
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
return NextResponse.json(rows[0] ?? null);
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
}
// POST — connect an organisation to this portfolio (Domna only)
// GET — fetch all linked organisations for this portfolio
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await params;
const rows = await linkedOrgsQuery(portfolioId);
return NextResponse.json(rows);
}
// POST — add an organisation link (Domna only, rejects duplicates)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
@ -43,40 +44,40 @@ export async function POST(
const { portfolioId } = await params;
const body = await req.json();
const { organisationId } = body as { organisationId: string };
const { organisationId } = body as { organisationId?: string };
if (!organisationId) {
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
}
// Upsert: delete any existing link then insert fresh
await db
.delete(portfolioOrganisation)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
// Reject if this org is already linked to this portfolio
const existing = await db
.select({ id: portfolioOrganisation.id })
.from(portfolioOrganisation)
.where(
and(
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
eq(portfolioOrganisation.organisationId, organisationId),
),
)
.limit(1);
if (existing.length > 0) {
return NextResponse.json({ error: "Organisation already linked" }, { status: 409 });
}
await db.insert(portfolioOrganisation).values({
portfolioId: BigInt(portfolioId),
organisationId,
});
// Return the newly linked org
const rows = await db
.select({
id: organisation.id,
name: organisation.name,
hubspotCompanyId: organisation.hubspotCompanyId,
})
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
return NextResponse.json(rows[0] ?? null);
const rows = await linkedOrgsQuery(portfolioId);
return NextResponse.json(rows);
}
// DELETE — disconnect the organisation from this portfolio (Domna only)
// DELETE — remove a specific organisation link (Domna only)
export async function DELETE(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
@ -85,10 +86,21 @@ export async function DELETE(
}
const { portfolioId } = await params;
const body = await req.json().catch(() => ({}));
const { organisationId } = body as { organisationId?: string };
if (!organisationId) {
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
}
await db
.delete(portfolioOrganisation)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
.where(
and(
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
eq(portfolioOrganisation.organisationId, organisationId),
),
);
return NextResponse.json({ success: true });
}

View file

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

View file

@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const {
mockGetServerSession,
mockUpdatePibiRequest,
mockDeletePibiRequest,
mockDbSelect,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockUpdatePibiRequest: vi.fn(),
mockDeletePibiRequest: vi.fn(),
mockDbSelect: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("@/app/lib/updatePibiRequest", () => ({
updatePibiRequest: mockUpdatePibiRequest,
PIBI_ORDERED_TEXT_PROP: "measures_for_pibi_ordered_text",
}));
vi.mock("@/app/lib/deletePibiRequest", () => ({
deletePibiRequest: mockDeletePibiRequest,
}));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioUsers: { portfolioId: {}, userId: {}, role: {} },
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} },
}));
vi.mock("@/app/db/schema/users", () => ({ user: { id: {}, email: {} } }));
vi.mock("@/app/db/db", () => ({
db: { get select() { return mockDbSelect; } },
}));
function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) {
const self: Record<string, unknown> = {};
self["then"] = (_resolve: (v: unknown) => unknown, _reject: (e: unknown) => unknown) =>
Promise.resolve(directResult).then(_resolve, _reject);
self["from"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
function mockApproverAuth(userId = 2n) {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: userId }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
}
function makeParams(portfolioId = "5", id = "7") {
return Promise.resolve({ portfolioId, id });
}
import { PATCH, DELETE } from "./route";
describe("PATCH /pibi-requests/[id]", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdatePibiRequest.mockResolvedValue({ ok: true, hubspotSync: "ok" });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", {
method: "PATCH",
body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }),
headers: { "content-type": "application/json" },
});
const res = await PATCH(req, { params: makeParams() });
expect(res.status).toBe(401);
});
it("returns 403 when not approver", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "user@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", {
method: "PATCH",
body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }),
headers: { "content-type": "application/json" },
});
const res = await PATCH(req, { params: makeParams() });
expect(res.status).toBe(403);
});
it("updates PIBI and returns ok=true", async () => {
mockApproverAuth();
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", {
method: "PATCH",
body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }),
headers: { "content-type": "application/json" },
});
const res = await PATCH(req, { params: makeParams() });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.hubspotSync).toBe("ok");
expect(mockUpdatePibiRequest).toHaveBeenCalledWith(
expect.objectContaining({ id: 7n, dealId: "deal-1" }),
);
});
});
describe("DELETE /pibi-requests/[id]", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDeletePibiRequest.mockResolvedValue({ ok: true, hubspotSync: "ok" });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7?dealId=deal-1", {
method: "DELETE",
});
const res = await DELETE(req, { params: makeParams() });
expect(res.status).toBe(401);
});
it("deletes PIBI and returns ok=true", async () => {
mockApproverAuth();
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7?dealId=deal-1", {
method: "DELETE",
});
const res = await DELETE(req, { params: makeParams() });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(mockDeletePibiRequest).toHaveBeenCalledWith(
expect.objectContaining({ id: 7n, dealId: "deal-1" }),
);
});
});

View file

@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { updatePibiRequest } from "@/app/lib/updatePibiRequest";
import { deletePibiRequest } from "@/app/lib/deletePibiRequest";
const patchSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
measureName: z.string().min(1).optional(),
orderedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().nullable().optional(),
});
async function resolveApprover(
email: string,
portfolioId: string,
): Promise<
| { ok: true; userId: bigint }
| { ok: false; status: 401 | 403 | 404; error: string }
> {
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1);
if (!userRow[0]) return { ok: false, status: 404, error: "User not found" };
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return { ok: false, status: 403, error: "No portfolio access" };
}
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.map((r) => r.capability).includes("approver")) {
return { ok: false, status: 403, error: "Approver capability required" };
}
return { ok: true, userId: userRow[0].id };
}
/**
* PATCH /api/portfolio/[portfolioId]/pibi-requests/[id]
*
* Approver-only. Updates a PIBI request row.
* Body: { dealId: string, measureName?: string, orderedAt?: string, completedAt?: string | null }
*/
export async function PATCH(
req: NextRequest,
props: { params: Promise<{ portfolioId: string; id: string }> },
) {
const { portfolioId, id } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const auth = await resolveApprover(session.user.email, portfolioId);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { dealId, measureName, orderedAt, completedAt } = parsed.data;
const result = await updatePibiRequest({
id: BigInt(id),
dealId,
updates: {
...(measureName !== undefined && { measureName }),
...(orderedAt !== undefined && { orderedAt: new Date(orderedAt) }),
...(completedAt !== undefined && { completedAt: completedAt ? new Date(completedAt) : null }),
},
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({
ok: true,
hubspotSync: result.hubspotSync,
hubspotError: result.hubspotError,
});
}
/**
* DELETE /api/portfolio/[portfolioId]/pibi-requests/[id]?dealId=...
*
* Approver-only. Deletes a PIBI request row and re-syncs HubSpot.
*/
export async function DELETE(
req: NextRequest,
props: { params: Promise<{ portfolioId: string; id: string }> },
) {
const { portfolioId, id } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const dealId = req.nextUrl.searchParams.get("dealId");
if (!dealId) {
return NextResponse.json({ error: "dealId query param is required" }, { status: 400 });
}
const auth = await resolveApprover(session.user.email, portfolioId);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const result = await deletePibiRequest({ id: BigInt(id), dealId });
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({
ok: true,
hubspotSync: result.hubspotSync,
hubspotError: result.hubspotError,
});
}

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
const {
mockGetServerSession,
mockCreatePibiRequests,
mockDbSelect,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockCreatePibiRequests: vi.fn(),
mockDbSelect: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("@/app/lib/createPibiRequests", () => ({
createPibiRequests: mockCreatePibiRequests,
PIBI_ORDERED_TEXT_PROP: "measures_for_pibi_ordered_text",
}));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
desc: vi.fn((col: unknown) => ({ $desc: col })),
}));
vi.mock("@/app/db/schema/pibi_requests", () => ({
pibiRequests: {
id: {}, hubspotDealId: {}, portfolioId: {}, measureName: {},
orderedAt: {}, completedAt: {}, createdByUserId: {}, pushedAt: {},
},
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioUsers: { portfolioId: {}, userId: {}, role: {} },
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} },
}));
vi.mock("@/app/db/schema/users", () => ({ user: { id: {}, email: {} } }));
vi.mock("@/app/db/db", () => ({
db: { get select() { return mockDbSelect; } },
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) {
const self: Record<string, unknown> = {};
self["then"] = (_resolve: (v: unknown) => unknown, _reject: (e: unknown) => unknown) =>
Promise.resolve(directResult).then(_resolve, _reject);
self["from"] = vi.fn(() => self);
self["innerJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["orderBy"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
function mockApproverAuth(userId = 2n) {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: userId, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
}
function makeRequest(body: unknown, portfolioId = "5") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/pibi-requests`,
{ method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } },
);
return { req, params: Promise.resolve({ portfolioId }) };
}
function makeGetRequest(dealId: string, portfolioId = "5") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/pibi-requests?dealId=${dealId}`,
);
return { req, params: Promise.resolve({ portfolioId }) };
}
import { GET, POST } from "./route";
describe("GET /pibi-requests", () => {
beforeEach(() => vi.clearAllMocks());
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeGetRequest("deal-1");
const res = await GET(req, { params });
expect(res.status).toBe(401);
});
it("returns 400 when dealId missing", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "x@test.com" } });
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests");
const res = await GET(req, { params: Promise.resolve({ portfolioId: "5" }) });
expect(res.status).toBe(400);
});
it("returns pibi requests for deal", async () => {
const orderedAt = new Date("2026-05-06T10:00:00Z");
mockGetServerSession.mockResolvedValue({ user: { email: "x@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([], [
{ id: 1n, measureName: "CWI", orderedAt, completedAt: null },
{ id: 2n, measureName: "Loft insulation", orderedAt, completedAt: null },
])
);
const { req, params } = makeGetRequest("deal-1");
const res = await GET(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.pibiRequests).toHaveLength(2);
});
});
describe("POST /pibi-requests", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreatePibiRequests.mockResolvedValue({ ok: true, insertedRowIds: [1n], hubspotSync: "ok" });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI"] });
const res = await POST(req, { params });
expect(res.status).toBe(401);
});
it("returns 403 when user lacks approver capability", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "write@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI"] });
const res = await POST(req, { params });
expect(res.status).toBe(403);
});
it("returns 400 when measureNames is empty", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
const { req, params } = makeRequest({ dealId: "deal-1", measureNames: [] });
const res = await POST(req, { params });
expect(res.status).toBe(400);
});
it("creates PIBIs and returns ok=true with insertedCount", async () => {
mockApproverAuth();
const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI", "ASHP"] });
const res = await POST(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.hubspotSync).toBe("ok");
expect(json.insertedCount).toBe(1);
});
});

View file

@ -0,0 +1,189 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { and, eq, desc } from "drizzle-orm";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { pibiRequests } from "@/app/db/schema/pibi_requests";
import { createPibiRequests } from "@/app/lib/createPibiRequests";
const postSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
measureNames: z.array(z.string().min(1)).min(1, "at least one measure required"),
orderedAt: z.string().datetime().optional(),
});
async function resolveApprover(
email: string,
portfolioId: string,
): Promise<
| { ok: true; userId: bigint }
| { ok: false; status: 401 | 403 | 404; error: string }
> {
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1);
if (!userRow[0]) return { ok: false, status: 404, error: "User not found" };
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return { ok: false, status: 403, error: "No portfolio access" };
}
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.map((r) => r.capability).includes("approver")) {
return { ok: false, status: 403, error: "Approver capability required" };
}
return { ok: true, userId: userRow[0].id };
}
/**
* GET /api/portfolio/[portfolioId]/pibi-requests?dealId=...
*
* Returns all PIBI requests for a deal, ordered by orderedAt desc.
* Response: { pibiRequests: PibiRequestRow[] }
*/
export async function GET(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const dealId = req.nextUrl.searchParams.get("dealId");
if (!dealId) {
return NextResponse.json({ error: "dealId query param is required" }, { status: 400 });
}
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, session.user.email))
.limit(1);
if (!userRow[0]) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return NextResponse.json({ error: "No portfolio access" }, { status: 403 });
}
const rows = await db
.select({
id: pibiRequests.id,
measureName: pibiRequests.measureName,
orderedAt: pibiRequests.orderedAt,
completedAt: pibiRequests.completedAt,
})
.from(pibiRequests)
.where(eq(pibiRequests.hubspotDealId, dealId))
.orderBy(desc(pibiRequests.orderedAt));
return NextResponse.json({
pibiRequests: rows.map((r) => ({
id: String(r.id),
measureName: r.measureName,
orderedAt: r.orderedAt,
completedAt: r.completedAt,
})),
});
}
/**
* POST /api/portfolio/[portfolioId]/pibi-requests
*
* Approver-only. Creates one pibi_request row per measure in the batch.
* Body: { dealId: string, measureNames: string[], orderedAt?: string (ISO) }
* Response: { ok: true, insertedCount: number, hubspotSync: "ok" | "failed" }
*/
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = postSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const auth = await resolveApprover(session.user.email, portfolioId);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { dealId, measureNames, orderedAt } = parsed.data;
const result = await createPibiRequests({
dealId,
portfolioId: BigInt(portfolioId),
measureNames,
orderedAt: orderedAt ? new Date(orderedAt) : undefined,
userId: auth.userId,
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({
ok: true,
insertedCount: result.insertedRowIds.length,
hubspotSync: result.hubspotSync,
hubspotError: result.hubspotError,
});
}

View file

@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
const {
mockGetServerSession,
mockSyncSurveyRequestToHubSpot,
mockDbSelect,
mockDbInsert,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockSyncSurveyRequestToHubSpot: vi.fn(),
mockDbSelect: vi.fn(),
mockDbInsert: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({
AuthOptions: {},
}));
vi.mock("@/app/lib/hubspot/dealSync", () => ({
syncSurveyRequestToHubSpot: mockSyncSurveyRequestToHubSpot,
}));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
desc: vi.fn((col: unknown) => ({ $desc: col })),
}));
vi.mock("@/app/db/schema/survey_requests", () => ({
surveyRequests: {
id: {}, hubspotDealId: {}, portfolioId: {}, notes: {},
surveyType: {}, status: {}, requestedBy: {}, requestedAt: {}, fulfilledAt: {},
},
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioUsers: { portfolioId: {}, userId: {}, role: {} },
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} },
}));
vi.mock("@/app/db/schema/users", () => ({
user: { id: {}, email: {} },
}));
vi.mock("@/app/db/db", () => ({
db: {
get select() { return mockDbSelect; },
get insert() { return mockDbInsert; },
},
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) {
const self: Record<string, unknown> = {};
self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(directResult).then(resolve, reject);
self["from"] = vi.fn(() => self);
self["innerJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["orderBy"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
function makeInsertChain(returningResult: unknown[] = []) {
const returning = vi.fn(() => Promise.resolve(returningResult));
const values = vi.fn(() => ({ returning }));
return { values };
}
function makeRequest(body: unknown, portfolioId = "5") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/survey-requests`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
return { req, params: Promise.resolve({ portfolioId }) };
}
// ── Subject under test ────────────────────────────────────────────────────────
import { POST } from "./route";
describe("POST /survey-requests", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: true });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(401);
});
it("returns 403 when user lacks approver capability", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "write@test.com" } });
// user lookup
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n, email: "write@test.com" }]));
// portfolio role check
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }]));
// capability check — no rows (directResult), so not an approver
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(403);
const json = await res.json();
expect(json.error).toMatch(/approver/i);
});
it("returns 409 when a pending request already exists for the deal", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
// capability rows come back via directResult (no .limit() on that query)
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
// pending check — returns a pending row
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n, status: "pending" }]));
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(409);
const json = await res.json();
expect(json.error).toMatch(/pending/i);
});
it("creates the request with surveyType and syncs to HubSpot", async () => {
const insertedAt = new Date("2026-05-06T10:00:00.000Z");
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
// no pending request
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
// insert returning
mockDbInsert.mockImplementationOnce(() =>
makeInsertChain([{ id: 42n, requestedAt: insertedAt }])
);
const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.id).toBe("42");
expect(json.hubspotSync).toBe("ok");
expect(mockSyncSurveyRequestToHubSpot).toHaveBeenCalledWith({
hubspotDealId: "deal-abc",
surveyType: "technical_building_survey",
requestedAt: insertedAt,
});
});
it("returns hubspotSync: failed but still 200 when HubSpot fails", async () => {
const insertedAt = new Date();
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
mockDbInsert.mockImplementationOnce(() =>
makeInsertChain([{ id: 43n, requestedAt: insertedAt }])
);
mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: false, error: "HubSpot sync failed" });
const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.hubspotSync).toBe("failed");
expect(json.hubspotError).toBe("HubSpot sync failed");
});
});

View file

@ -0,0 +1,208 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { surveyRequests } from "@/app/db/schema/survey_requests";
import { portfolioUsers, portfolioCapabilities } 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";
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 hasPortfolioRole(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;
}
async function hasApproverCapability(portfolioId: bigint, userId: bigint) {
const rows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, portfolioId),
eq(portfolioCapabilities.userId, userId),
),
);
return rows.map((r) => r.capability).includes("approver");
}
async function getPendingRequest(hubspotDealId: string, portfolioId: bigint) {
const rows = await db
.select({ id: surveyRequests.id, status: surveyRequests.status })
.from(surveyRequests)
.where(
and(
eq(surveyRequests.hubspotDealId, hubspotDealId),
eq(surveyRequests.portfolioId, portfolioId),
eq(surveyRequests.status, "pending"),
),
)
.limit(1);
return rows[0] ?? null;
}
// GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx
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,
surveyType: surveyRequests.surveyType,
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,
surveyType: r.surveyType ?? null,
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),
surveyType: z.string().min(1),
});
// POST /api/portfolio/[portfolioId]/survey-requests
// Submit a new survey request — requires approver capability.
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 pid = BigInt(portfolioId);
const isMember = await hasPortfolioRole(pid, requestingUser.id);
if (!isMember) {
return NextResponse.json({ error: "No portfolio access" }, { status: 403 });
}
const isApprover = await hasApproverCapability(pid, requestingUser.id);
if (!isApprover) {
return NextResponse.json(
{ error: "Approver capability 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, surveyType } = parsed.data;
const existing = await getPendingRequest(hubspotDealId, pid);
if (existing) {
return NextResponse.json(
{ error: "A pending survey request already exists for this deal" },
{ status: 409 },
);
}
try {
const [inserted] = await db
.insert(surveyRequests)
.values({
hubspotDealId,
portfolioId: pid,
notes: "",
surveyType,
status: "pending",
requestedBy: requestingUser.id,
})
.returning({ id: surveyRequests.id, requestedAt: surveyRequests.requestedAt });
const hubspotResult = await syncSurveyRequestToHubSpot({
hubspotDealId,
surveyType,
requestedAt: inserted.requestedAt,
});
return NextResponse.json({
ok: true,
id: String(inserted.id),
hubspotSync: hubspotResult.ok ? "ok" : "failed",
hubspotError: hubspotResult.error,
});
} catch (err) {
console.error("[survey-requests POST]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -31,7 +31,7 @@ export async function GET(
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(eq(tasks.sourceId, portfolioId))
.groupBy(tasks.id)
.orderBy(desc(tasks.updatedAt))
.orderBy(desc(tasks.jobStarted))
.limit(limit)
.offset(offset);

View file

@ -13,7 +13,7 @@ export async function GET(
.select()
.from(subTasks)
.where(eq(subTasks.taskId, taskId))
.orderBy(subTasks.updatedAt);
.orderBy(subTasks.jobStarted);
return NextResponse.json(taskSubTasks);
} catch (error) {

View file

@ -0,0 +1,40 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { eq, count, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ taskId: string }> }
) {
const { taskId } = await params;
try {
const [row] = await db
.select({
id: tasks.id,
taskSource: tasks.taskSource,
status: tasks.status,
service: tasks.service,
jobStarted: tasks.jobStarted,
jobCompleted: tasks.jobCompleted,
updatedAt: tasks.updatedAt,
totalSubtasks: count(subTasks.id),
completedSubtasks: sql<number>`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`,
failedSubtasks: sql<number>`count(case when lower(${subTasks.status}) in ('failed', 'failure', 'error') then 1 end)::int`,
})
.from(tasks)
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(eq(tasks.id, taskId))
.groupBy(tasks.id)
.limit(1);
if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(row);
} catch (error) {
console.error("Error fetching task summary:", error);
return NextResponse.json({ error: "Failed to fetch task summary" }, { status: 500 });
}
}

View file

@ -0,0 +1,55 @@
import { db } from "@/app/db/db";
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const BodySchema = z.object({
fileKey: z.string(),
filename: z.string(),
portfolioId: z.string(),
userId: z.string(),
sourceHeaders: z
.array(z.union([z.string(), z.null(), z.undefined()]))
.default([])
.transform((arr) =>
arr.filter((h): h is string => typeof h === "string" && h.trim().length > 0)
),
});
export async function POST(request: NextRequest) {
let body;
try {
body = BodySchema.parse(await request.json());
} catch (error) {
console.error("Invalid input:", error);
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const bucket = process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME;
if (!bucket) {
console.error("RETROFIT_PLAN_INPUT_BUCKET_NAME not set");
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
try {
const [record] = await db
.insert(bulkAddressUploads)
.values({
portfolioId: body.portfolioId,
userId: body.userId,
s3Bucket: bucket,
s3Key: body.fileKey,
filename: body.filename,
sourceHeaders: body.sourceHeaders,
})
.returning();
return NextResponse.json(
{ id: record.id, s3Key: record.s3Key, s3Bucket: record.s3Bucket, status: record.status },
{ status: 201 }
);
} catch (error) {
console.error("Failed to record upload:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,36 @@
import { createS3Client } from "@/app/utils/s3";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const BodySchema = z.object({
userId: z.string(),
portfolioId: z.string(),
fileKey: z.string(),
contentType: z.string(),
});
export async function POST(request: NextRequest) {
let body;
try {
body = BodySchema.parse(await request.json());
} catch (error) {
console.error("Invalid input:", error);
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const s3 = createS3Client();
const preSignedUrl = await s3.getSignedUrlPromise("putObject", {
Bucket: process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME,
Key: body.fileKey,
ContentType: body.contentType,
Expires: 5 * 60,
});
return NextResponse.json({ url: preSignedUrl }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -35,6 +35,16 @@ export default function AddNew({
router.push(`/portfolio/${portfolioId}/remote-assessment`);
}
function handleBulkUploadClick() {
const pw = window.prompt("Enter password to access bulk upload");
if (pw === null) return;
if (pw === "domnatechteamonly") {
setIsBulkUploadOpen(true);
} else {
window.alert("Incorrect password");
}
}
return (
<>
<BulkUploadComingSoonModal
@ -118,7 +128,7 @@ export default function AddNew({
<MenuItem>
{({ active }) => (
<button
onClick={() => setIsBulkUploadOpen(true)}
onClick={handleBulkUploadClick}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100"

View file

@ -4,11 +4,33 @@ import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from "@headlessui/react";
import { Fragment } from "react";
import { XMarkIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import { Fragment, useRef, useState, DragEvent } from "react";
import * as XLSX from "xlsx";
import {
XMarkIcon,
DocumentTextIcon,
ArrowDownTrayIcon,
CloudArrowUpIcon,
InformationCircleIcon,
ArrowRightIcon,
CheckCircleIcon,
ExclamationCircleIcon,
} from "@heroicons/react/24/outline";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useCreateBulkUpload } from "@/lib/bulkUpload/client";
const MAX_FILE_SIZE_MB = 50;
const ALLOWED_EXTENSIONS = [".csv", ".xlsx", ".xls"];
const CONTENT_TYPES: Record<string, string> = {
".csv": "text/csv",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
};
interface BulkUploadComingSoonModalProps {
isOpen: boolean;
@ -16,13 +38,180 @@ interface BulkUploadComingSoonModalProps {
portfolioId: string;
}
function downloadTemplate() {
const ws = XLSX.utils.aoa_to_sheet([["Internal Reference (Optional)", "Address", "Postcode"]]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Properties");
XLSX.writeFile(wb, "bulk_upload_template.xlsx");
}
function getFileExtension(filename: string): string {
return filename.slice(filename.lastIndexOf(".")).toLowerCase();
}
function generateS3Key(userId: string, portfolioId: string, ext: string): string {
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
return `bulk-addresses/${userId}/${portfolioId}/${timestamp}/addresses${ext}`;
}
function validateFile(file: File): string | null {
const sizeMB = file.size / (1024 * 1024);
if (sizeMB > MAX_FILE_SIZE_MB) {
return `File too large. Max ${MAX_FILE_SIZE_MB}MB.`;
}
const ext = getFileExtension(file.name);
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return "Only CSV or Excel files allowed.";
}
return null;
}
async function validateHeaders(file: File): Promise<{ error: string | null; headers: string[] }> {
const ext = getFileExtension(file.name);
let headers: string[] = [];
if (ext === ".csv") {
const text = await file.text();
const firstLine = text.split(/\r?\n/)[0] ?? "";
headers = firstLine.split(",").map((h) => h.trim().replace(/^["']|["']$/g, ""));
} else {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { sheetRows: 1 });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: "" });
headers = ((rows[0] as unknown[]) ?? []).map((h) => String(h ?? "").trim());
}
headers = headers.filter((h) => h.length > 0);
const normalised = headers.map((h) => h.toLowerCase());
const hasAddress = normalised.some((h) => h.startsWith("address"));
const hasPostcode = normalised.some((h) => h === "postcode");
if (!hasAddress && !hasPostcode) {
return { error: "Missing required columns: Address and Postcode.", headers };
}
if (!hasAddress) {
return { error: "Missing required column: Address (or Address 1, Address 2, etc.).", headers };
}
if (!hasPostcode) {
return { error: 'Missing required column: "Postcode".', headers };
}
return { error: null, headers };
}
export default function BulkUploadComingSoonModal({
isOpen,
onClose,
portfolioId,
}: BulkUploadComingSoonModalProps) {
const session = useSession();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const createUpload = useCreateBulkUpload();
const [isDragging, setIsDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [sourceHeaders, setSourceHeaders] = useState<string[]>([]);
const [validationError, setValidationError] = useState<string | null>(null);
const [validating, setValidating] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const uploading = createUpload.isPending;
const uploadError = createUpload.error
? "Upload failed. Please try again, or contact a Domna representative if the issue persists."
: null;
async function handleFile(file: File) {
createUpload.reset();
setSelectedFile(null);
setValidationError(null);
const sizeOrTypeError = validateFile(file);
if (sizeOrTypeError) {
setValidationError(sizeOrTypeError);
return;
}
setValidating(true);
const { error: headerError, headers } = await validateHeaders(file);
setValidating(false);
if (headerError) {
setValidationError(headerError);
return;
}
setSourceHeaders(headers);
setSelectedFile(file);
}
function handleDragOver(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDragging(true);
}
function handleDragLeave() {
setIsDragging(false);
}
function handleDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
}
function handleClose() {
setSelectedFile(null);
setSourceHeaders([]);
setValidationError(null);
setValidating(false);
setUploadProgress(null);
createUpload.reset();
onClose();
}
function handleUpload() {
const userId = String(session.data?.user?.dbId ?? "");
if (!selectedFile || !userId) return;
const ext = getFileExtension(selectedFile.name);
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
const fileKey = generateS3Key(userId, portfolioId, ext);
setUploadProgress(0);
createUpload.mutate(
{
file: selectedFile,
portfolioId,
userId,
sourceHeaders,
contentType,
fileKey,
onProgress: setUploadProgress,
},
{
onSuccess: ({ id: uploadId }) => {
router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}/map-columns`);
onClose();
},
onSettled: () => setUploadProgress(null),
}
);
}
const canUpload = !!selectedFile && !uploading && !validating;
return (
<Transition show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[9999]" onClose={handleClose}>
{/* Backdrop */}
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
@ -32,52 +221,204 @@ export default function BulkUploadComingSoonModal({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<DialogBackdrop className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
<DialogBackdrop className="fixed inset-0 bg-gray-900/40 backdrop-blur-sm" />
</TransitionChild>
{/* Panel */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
enterFrom="opacity-0 scale-95 translate-y-2"
enterTo="opacity-100 scale-100 translate-y-0"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
leaveFrom="opacity-100 scale-100 translate-y-0"
leaveTo="opacity-0 scale-95 translate-y-2"
>
<DialogPanel className="w-full max-w-md bg-white rounded-2xl shadow-xl p-8 relative">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
<div className="flex flex-col items-center text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center">
<RectangleStackIcon className="h-7 w-7 text-amber-500" />
</div>
<DialogPanel className="w-full max-w-2xl bg-white rounded-2xl shadow-[0_40px_60px_-15px_rgba(21,29,33,0.15)] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-10 pt-10 pb-6 flex justify-between items-start">
<div>
<span className="text-[11px] font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full">
Coming Soon
</span>
<h2 className="mt-3 text-2xl font-extrabold text-gray-900 tracking-tight">
Bulk Address Upload
</h2>
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
Upload multiple addresses in one go. This feature is currently in development
and will be available soon.
<DialogTitle className="text-2xl font-extrabold text-gray-900 tracking-tight mb-2">
Bulk Upload: New Properties
</DialogTitle>
<p className="text-sm text-gray-500 leading-relaxed max-w-md">
This workflow is designed for adding new residential or commercial
assets to your portfolio. Upload your dataset to begin the
transformation.
</p>
</div>
<button
onClick={onClose}
className="mt-2 px-6 py-2.5 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
onClick={handleClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors ml-4 shrink-0"
>
Got it
<XMarkIcon className="h-5 w-5 text-gray-400" />
</button>
</div>
{/* Content */}
<div className="px-10 py-2 space-y-5">
{/* Template section */}
<div className="bg-gray-50 p-5 rounded-xl flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-11 h-11 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
<DocumentTextIcon className="h-6 w-6 text-midblue" />
</div>
<div>
<p className="text-sm font-semibold text-gray-900">Required Template Format</p>
<p className="text-xs text-gray-500 mt-0.5">
Must contain:{" "}
<span className="font-medium text-midblue">
Address, Postcode
</span>
</p>
</div>
</div>
<button
onClick={downloadTemplate}
className="flex items-center gap-1.5 text-xs font-semibold text-midblue hover:text-gray-900 transition-colors shrink-0 ml-4"
>
<ArrowDownTrayIcon className="h-4 w-4" />
Download template
</button>
</div>
{/* Dropzone */}
<div
onClick={() => !uploading && fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-2xl p-12 flex flex-col items-center justify-center transition-colors ${
uploading || validating
? "border-gray-200 bg-gray-50 cursor-default"
: validationError
? "border-red-300 bg-red-50 cursor-pointer"
: isDragging
? "border-midblue bg-blue-50 cursor-copy"
: selectedFile
? "border-green-400 bg-green-50 cursor-pointer"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50 cursor-pointer"
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleInputChange}
onClick={(e) => e.stopPropagation()}
/>
{validating ? (
<>
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<span className="h-6 w-6 rounded-full border-2 border-midblue border-t-transparent animate-spin" />
</div>
<p className="text-base font-bold text-gray-900 mb-1">Checking headers</p>
<p className="text-xs text-gray-400">Validating column structure</p>
</>
) : uploading ? (
<>
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<CloudArrowUpIcon className="h-7 w-7 text-midblue" />
</div>
<p className="text-base font-bold text-gray-900 mb-1">Uploading</p>
<p className="text-xs text-gray-400 mb-4">{selectedFile?.name}</p>
<div className="w-full max-w-xs bg-gray-200 rounded-full h-2">
<div
className="bg-midblue h-2 rounded-full transition-all duration-200"
style={{ width: `${uploadProgress ?? 0}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{uploadProgress ?? 0}%</p>
</>
) : validationError ? (
<>
<ExclamationCircleIcon className="h-14 w-14 text-red-400 mb-4" />
<p className="text-base font-bold text-red-600 mb-1">{validationError}</p>
<p className="text-xs text-gray-400">Click to choose a different file</p>
</>
) : selectedFile ? (
<>
<CheckCircleIcon className="h-14 w-14 text-green-400 mb-4" />
<p className="text-base font-bold text-gray-900 mb-1">{selectedFile.name}</p>
<p className="text-xs text-gray-400">Click to change file</p>
</>
) : (
<>
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<CloudArrowUpIcon className="h-7 w-7 text-midblue" />
</div>
<p className="text-base font-bold text-gray-900 mb-1">
Drag and drop CSV or XLSX
</p>
<p className="text-xs text-gray-400">
or <span className="text-midblue font-semibold">click to browse</span> · Max {MAX_FILE_SIZE_MB}MB
</p>
</>
)}
</div>
{/* Upload error */}
{uploadError && (
<p className="text-xs text-red-500 flex items-center gap-1.5">
<ExclamationCircleIcon className="h-4 w-4 shrink-0" />
{uploadError}
</p>
)}
{/* Info strip */}
<div className="flex items-center gap-3 text-xs text-gray-500 bg-gray-50 px-4 py-3 rounded-lg">
<InformationCircleIcon className="h-4 w-4 text-midblue shrink-0" />
<span>
Properties will be automatically validated against national
architectural databases.
</span>
</div>
</div>
{/* Footer */}
<div className="px-10 py-7 mt-4 flex items-center justify-between bg-gray-50/50">
<div className="flex items-center gap-4">
<button
onClick={handleClose}
className="text-sm font-semibold text-gray-400 hover:text-gray-700 transition-colors"
>
Cancel and Exit
</button>
<button
onClick={() => { handleClose(); router.push(`/portfolio/${portfolioId}/bulk-upload`); }}
className="text-sm font-semibold text-midblue hover:text-gray-900 transition-colors"
>
View previous uploads
</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleUpload}
disabled={!canUpload}
className={`flex items-center gap-2 px-7 py-2.5 rounded-2xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
canUpload ? "opacity-100 hover:opacity-90" : "opacity-40 cursor-not-allowed"
}`}
>
{uploading ? (
<>
<span className="h-4 w-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
Uploading
</>
) : (
<>
Upload File
<ArrowRightIcon className="h-4 w-4" />
</>
)}
</button>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>

View file

@ -1,2 +0,0 @@
ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;--> statement-breakpoint
ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text;

View file

@ -0,0 +1,3 @@
ALTER TABLE "property" ADD COLUMN "user_inputted_address" text;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "user_inputted_postcode" text;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "lexiscore" real;

View file

@ -0,0 +1,261 @@
CREATE TYPE "public"."energy_element_type" AS ENUM('roof', 'wall', 'floor', 'main_heating', 'window', 'lighting', 'hot_water', 'secondary_heating', 'main_heating_controls');--> statement-breakpoint
CREATE TABLE "epc_building_part" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"identifier" text NOT NULL,
"construction_age_band" text NOT NULL,
"wall_construction" text NOT NULL,
"wall_insulation_type" text NOT NULL,
"wall_thickness_measured" boolean NOT NULL,
"party_wall_construction" text NOT NULL,
"building_part_number" integer,
"wall_dry_lined" boolean,
"wall_thickness_mm" integer,
"wall_insulation_thickness" text,
"floor_heat_loss" integer,
"floor_insulation_thickness" text,
"flat_roof_insulation_thickness" text,
"floor_type" text,
"floor_construction_type" text,
"floor_insulation_type_str" text,
"floor_u_value_known" boolean,
"roof_construction" integer,
"roof_insulation_location" text,
"roof_insulation_thickness" text,
"room_in_roof_floor_area" real,
"room_in_roof_construction_age_band" text,
"alt_wall_1_area" real,
"alt_wall_1_dry_lined" text,
"alt_wall_1_construction" integer,
"alt_wall_1_insulation_type" integer,
"alt_wall_1_thickness_measured" text,
"alt_wall_1_insulation_thickness" text,
"alt_wall_2_area" real,
"alt_wall_2_dry_lined" text,
"alt_wall_2_construction" integer,
"alt_wall_2_insulation_type" integer,
"alt_wall_2_thickness_measured" text,
"alt_wall_2_insulation_thickness" text
);
--> statement-breakpoint
CREATE TABLE "epc_energy_element" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"element_type" "energy_element_type" NOT NULL,
"description" text NOT NULL,
"energy_efficiency_rating" integer NOT NULL,
"environmental_efficiency_rating" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "epc_flat_details" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"level" integer NOT NULL,
"top_storey" text NOT NULL,
"flat_location" integer NOT NULL,
"heat_loss_corridor" integer NOT NULL,
"storey_count" integer,
"unheated_corridor_length_m" integer,
CONSTRAINT "epc_flat_details_epc_property_id_unique" UNIQUE("epc_property_id")
);
--> statement-breakpoint
CREATE TABLE "epc_floor_dimension" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_building_part_id" bigint NOT NULL,
"floor" integer,
"room_height_m" real NOT NULL,
"total_floor_area_m2" real NOT NULL,
"party_wall_length_m" real NOT NULL,
"heat_loss_perimeter_m" real NOT NULL,
"floor_insulation" integer,
"floor_construction" integer
);
--> statement-breakpoint
CREATE TABLE "epc_main_heating_detail" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"has_fghrs" boolean NOT NULL,
"main_fuel_type" text NOT NULL,
"heat_emitter_type" text NOT NULL,
"emitter_temperature" text NOT NULL,
"main_heating_control" text NOT NULL,
"fan_flue_present" boolean,
"boiler_flue_type" integer,
"boiler_ignition_type" integer,
"central_heating_pump_age" integer,
"central_heating_pump_age_str" text,
"main_heating_index_number" integer,
"sap_main_heating_code" integer,
"main_heating_number" integer,
"main_heating_category" integer,
"main_heating_fraction" integer,
"main_heating_data_source" integer,
"condensing" boolean,
"weather_compensator" boolean
);
--> statement-breakpoint
CREATE TABLE "epc_property" (
"id" bigserial PRIMARY KEY NOT NULL,
"property_id" bigint NOT NULL,
"portfolio_id" bigint NOT NULL,
"uprn" bigint,
"uprn_source" text,
"report_reference" text,
"report_type" text,
"assessment_type" text,
"sap_version" real,
"schema_type" text,
"schema_versions_original" text,
"status" text,
"calculation_software_version" text,
"address_line_1" text,
"address_line_2" text,
"post_town" text,
"postcode" text,
"region_code" text,
"country_code" text,
"language_code" text,
"dwelling_type" text NOT NULL,
"property_type" text,
"built_form" text,
"tenure" text NOT NULL,
"transaction_type" text NOT NULL,
"inspection_date" timestamp NOT NULL,
"completion_date" timestamp,
"registration_date" timestamp,
"total_floor_area_m2" real NOT NULL,
"measurement_type" integer,
"solar_water_heating" boolean NOT NULL,
"has_hot_water_cylinder" boolean NOT NULL,
"has_fixed_air_conditioning" boolean NOT NULL,
"has_conservatory" boolean,
"has_heated_separate_conservatory" boolean,
"conservatory_type" integer,
"door_count" integer NOT NULL,
"wet_rooms_count" integer NOT NULL,
"extensions_count" integer NOT NULL,
"heated_rooms_count" integer NOT NULL,
"open_chimneys_count" integer NOT NULL,
"habitable_rooms_count" integer NOT NULL,
"insulated_door_count" integer NOT NULL,
"cfl_fixed_lighting_bulbs_count" integer NOT NULL,
"led_fixed_lighting_bulbs_count" integer NOT NULL,
"incandescent_fixed_lighting_bulbs_count" integer NOT NULL,
"blocked_chimneys_count" integer,
"draughtproofed_door_count" integer,
"energy_rating_average" integer,
"low_energy_fixed_lighting_bulbs_count" integer,
"fixed_lighting_outlets_count" integer,
"low_energy_fixed_lighting_outlets_count" integer,
"number_of_storeys" integer,
"any_unheated_rooms" boolean,
"hydro" boolean,
"photovoltaic_array" boolean,
"waste_water_heat_recovery" text,
"pressure_test" integer,
"pressure_test_certificate_number" integer,
"percent_draughtproofed" integer,
"insulated_door_u_value" real,
"multiple_glazed_proportion" integer,
"windows_transmission_u_value" real,
"windows_transmission_data_source" integer,
"windows_transmission_solar_transmittance" real,
"energy_mains_gas" boolean NOT NULL,
"energy_meter_type" text NOT NULL,
"energy_pv_battery_count" integer NOT NULL,
"energy_wind_turbines_count" integer NOT NULL,
"energy_gas_smart_meter_present" boolean NOT NULL,
"energy_is_dwelling_export_capable" boolean NOT NULL,
"energy_wind_turbines_terrain_type" text NOT NULL,
"energy_electricity_smart_meter_present" boolean NOT NULL,
"energy_pv_connection" text,
"energy_pv_percent_roof_area" integer,
"energy_pv_battery_capacity" real,
"energy_wind_turbine_hub_height" real,
"energy_wind_turbine_rotor_diameter" real,
"heating_cylinder_size" text,
"heating_water_heating_code" integer,
"heating_water_heating_fuel" integer,
"heating_immersion_heating_type" text,
"heating_cylinder_insulation_type" text,
"heating_cylinder_thermostat" text,
"heating_secondary_fuel_type" integer,
"heating_secondary_heating_type" text,
"heating_cylinder_insulation_thickness_mm" integer,
"heating_wwhrs_index_number_1" integer,
"heating_wwhrs_index_number_2" integer,
"heating_shower_outlet_type" text,
"heating_shower_wwhrs" integer,
"ventilation_type" text,
"ventilation_draught_lobby" boolean,
"ventilation_pressure_test" text,
"ventilation_open_flues_count" integer,
"ventilation_closed_flues_count" integer,
"ventilation_boiler_flues_count" integer,
"ventilation_other_flues_count" integer,
"ventilation_extract_fans_count" integer,
"ventilation_passive_vents_count" integer,
"ventilation_flueless_gas_fires_count" integer,
"ventilation_in_pcdf_database" boolean,
"mechanical_ventilation" integer,
"mechanical_vent_duct_type" integer,
"mechanical_vent_duct_placement" integer,
"mechanical_vent_duct_insulation" integer,
"mechanical_ventilation_index_number" integer,
"mechanical_vent_measured_installation" text
);
--> statement-breakpoint
CREATE TABLE "epc_property_energy_performance" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"energy_rating_current" integer,
"energy_consumption_current" integer,
"environmental_impact_current" integer,
"heating_cost_current" real,
"lighting_cost_current" real,
"hot_water_cost_current" real,
"co2_emissions_current" real,
"co2_emissions_current_per_floor_area" integer,
"current_energy_efficiency_band" text,
"energy_rating_potential" real,
"energy_consumption_potential" integer,
"environmental_impact_potential" integer,
"heating_cost_potential" real,
"lighting_cost_potential" real,
"hot_water_cost_potential" real,
"co2_emissions_potential" real,
"potential_energy_efficiency_band" text,
CONSTRAINT "epc_property_energy_performance_epc_property_id_unique" UNIQUE("epc_property_id")
);
--> statement-breakpoint
CREATE TABLE "epc_window" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"pvc_frame" text NOT NULL,
"glazing_gap" text NOT NULL,
"orientation" text NOT NULL,
"window_type" text NOT NULL,
"glazing_type" text NOT NULL,
"window_width" real NOT NULL,
"window_height" real NOT NULL,
"draught_proofed" boolean NOT NULL,
"window_location" text NOT NULL,
"window_wall_type" text NOT NULL,
"permanent_shutters_present" boolean NOT NULL,
"frame_factor" real,
"permanent_shutters_insulated" text,
"transmission_u_value" real,
"transmission_data_source" integer,
"transmission_solar_transmittance" real
);
--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD CONSTRAINT "epc_building_part_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_energy_element" ADD CONSTRAINT "epc_energy_element_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_flat_details" ADD CONSTRAINT "epc_flat_details_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_floor_dimension" ADD CONSTRAINT "epc_floor_dimension_epc_building_part_id_epc_building_part_id_fk" FOREIGN KEY ("epc_building_part_id") REFERENCES "public"."epc_building_part"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_main_heating_detail" ADD CONSTRAINT "epc_main_heating_detail_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_property" ADD CONSTRAINT "epc_property_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_property" ADD CONSTRAINT "epc_property_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 "epc_property_energy_performance" ADD CONSTRAINT "epc_property_energy_performance_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_window" ADD CONSTRAINT "epc_window_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "uq_epc_property_property_portfolio" ON "epc_property" USING btree ("property_id","portfolio_id");

View file

@ -0,0 +1,3 @@
ALTER TABLE "epc_property" ALTER COLUMN "address_line_1" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_property" ALTER COLUMN "post_town" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_property" ALTER COLUMN "postcode" SET NOT NULL;

View file

@ -0,0 +1,2 @@
ALTER TABLE "epc_property" ALTER COLUMN "property_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_property" ALTER COLUMN "portfolio_id" DROP NOT NULL;

View file

@ -0,0 +1,2 @@
ALTER TABLE "epc_window" RENAME COLUMN "pvc_frame" TO "frame_material";--> statement-breakpoint
ALTER TABLE "epc_window" ALTER COLUMN "transmission_data_source" SET DATA TYPE text;

View file

@ -0,0 +1 @@
ALTER TABLE "epc_window" ALTER COLUMN "frame_material" DROP NOT NULL;

View file

@ -0,0 +1,2 @@
ALTER TABLE "epc_property" ADD COLUMN "uploaded_file_id" bigint;--> statement-breakpoint
ALTER TABLE "epc_property" ADD CONSTRAINT "epc_property_uploaded_file_id_uploaded_files_id_fk" FOREIGN KEY ("uploaded_file_id") REFERENCES "public"."uploaded_files"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1 @@
ALTER TABLE "epc_property" ADD CONSTRAINT "epc_property_uploaded_file_id_unique" UNIQUE("uploaded_file_id");

View file

@ -0,0 +1,10 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "survey_type" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "measures_for_pibi_ordered" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "pibi_order_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "pibi_completed_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "property_halted_date" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "property_halted_reason" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "technical_approved_measures_for_install" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "sent_to_installer_for_pricing" timestamp (6) with time zone;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_required" boolean;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_date" timestamp (6) with time zone;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,74 @@
CREATE TYPE "public"."user_defined_deal_measure_source" AS ENUM('instructed', 'pibi_ordered');--> statement-breakpoint
CREATE TABLE "magic_plan_door" (
"id" bigserial PRIMARY KEY NOT NULL,
"magic_plan_room_id" bigint NOT NULL,
"width_mm" real,
"type" text
);
--> statement-breakpoint
CREATE TABLE "magic_plan_floor" (
"id" bigserial PRIMARY KEY NOT NULL,
"magic_plan_plan_id" bigint NOT NULL,
"level" integer
);
--> statement-breakpoint
CREATE TABLE "magic_plan_plan" (
"id" bigserial PRIMARY KEY NOT NULL,
"name" text,
"address" text,
"postcode" text
);
--> statement-breakpoint
CREATE TABLE "magic_plan_room" (
"id" bigserial PRIMARY KEY NOT NULL,
"magic_plan_floor_id" bigint NOT NULL,
"name" text,
"width_m" real,
"length_m" real,
"area_m2" real
);
--> statement-breakpoint
CREATE TABLE "magic_plan_window" (
"id" bigserial PRIMARY KEY NOT NULL,
"magic_plan_room_id" bigint NOT NULL,
"width_m" real,
"height_m" real,
"area_m2" real,
"opening_type" text
);
--> statement-breakpoint
CREATE TABLE "survey_requests" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"portfolio_id" bigint NOT NULL,
"notes" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"requested_by" bigint NOT NULL,
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
"fulfilled_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "user_defined_deal_measures" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"measure_name" text NOT NULL,
"source" "user_defined_deal_measure_source" NOT NULL,
"created_by_user_id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"pushed_at" timestamp with time zone,
"confirmed_in_hubspot_at" timestamp with time zone,
"notes" text
);
--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_required" boolean;--> statement-breakpoint
ALTER TABLE "magic_plan_door" ADD CONSTRAINT "magic_plan_door_magic_plan_room_id_magic_plan_room_id_fk" FOREIGN KEY ("magic_plan_room_id") REFERENCES "public"."magic_plan_room"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "magic_plan_floor" ADD CONSTRAINT "magic_plan_floor_magic_plan_plan_id_magic_plan_plan_id_fk" FOREIGN KEY ("magic_plan_plan_id") REFERENCES "public"."magic_plan_plan"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "magic_plan_room" ADD CONSTRAINT "magic_plan_room_magic_plan_floor_id_magic_plan_floor_id_fk" FOREIGN KEY ("magic_plan_floor_id") REFERENCES "public"."magic_plan_floor"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "magic_plan_window" ADD CONSTRAINT "magic_plan_window_magic_plan_room_id_magic_plan_room_id_fk" FOREIGN KEY ("magic_plan_room_id") REFERENCES "public"."magic_plan_room"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_defined_deal_measures" ADD CONSTRAINT "user_defined_deal_measures_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_survey_requests_deal_id" ON "survey_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint
CREATE INDEX "idx_survey_requests_portfolio_id" ON "survey_requests" USING btree ("portfolio_id");--> statement-breakpoint
CREATE INDEX "idx_user_defined_deal_measures_deal_id" ON "user_defined_deal_measures" USING btree ("hubspot_deal_id");--> statement-breakpoint
CREATE INDEX "idx_user_defined_deal_measures_source" ON "user_defined_deal_measures" USING btree ("source");

View file

@ -0,0 +1,16 @@
CREATE TABLE "pibi_requests" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"portfolio_id" bigint NOT NULL,
"measure_name" text NOT NULL,
"ordered_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"created_by_user_id" bigint NOT NULL,
"pushed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "survey_requests" ADD COLUMN "survey_type" text;--> statement-breakpoint
ALTER TABLE "pibi_requests" ADD CONSTRAINT "pibi_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 "pibi_requests" ADD CONSTRAINT "pibi_requests_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_pibi_requests_deal_id" ON "pibi_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint
CREATE INDEX "idx_pibi_requests_portfolio_id" ON "pibi_requests" USING btree ("portfolio_id");

View file

@ -0,0 +1,2 @@
ALTER TABLE "portfolio_organisation" DROP CONSTRAINT "portfolio_organisation_portfolio_id_unique";--> statement-breakpoint
ALTER TABLE "portfolio_organisation" ADD CONSTRAINT "portfolio_organisation_portfolio_id_organisation_id_unique" UNIQUE("portfolio_id","organisation_id");

View file

@ -0,0 +1 @@
ALTER TABLE "magic_plan_plan" ADD COLUMN "magic_plan_uid" text;

View file

@ -0,0 +1,2 @@
ALTER TYPE "public"."file_source" ADD VALUE 'magic_plan';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'magic_plan_json';

View file

@ -0,0 +1 @@
ALTER TABLE "magic_plan_plan" ADD CONSTRAINT "magic_plan_plan_magic_plan_uid_unique" UNIQUE("magic_plan_uid");

View file

@ -0,0 +1 @@
ALTER TYPE "public"."source" ADD VALUE 'hubspot_deal_id';

View file

@ -0,0 +1,3 @@
ALTER TABLE "magic_plan_plan" ADD COLUMN "uploaded_file_id" bigint;--> statement-breakpoint
ALTER TABLE "magic_plan_plan" ADD CONSTRAINT "magic_plan_plan_uploaded_file_id_uploaded_files_id_fk" FOREIGN KEY ("uploaded_file_id") REFERENCES "public"."uploaded_files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "magic_plan_plan" ADD CONSTRAINT "magic_plan_plan_uploaded_file_id_unique" UNIQUE("uploaded_file_id");

View file

@ -0,0 +1,3 @@
ALTER TYPE "public"."file_type" ADD VALUE 'improvement_option_evaluation';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'medium_term_improvement_plan';--> statement-breakpoint
ALTER TYPE "public"."file_type" ADD VALUE 'retrofit_design_doc';

View file

@ -0,0 +1 @@
ALTER TYPE "public"."file_source" ADD VALUE 'coordination_hub';

View file

@ -303,6 +303,18 @@
"primaryKey": false,
"notNull": false
},
"task_id": {
"name": "task_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"combined_output_s3_uri": {
"name": "combined_output_s3_uri",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
@ -6467,8 +6479,8 @@
"schema": "public",
"values": [
"none",
"cladded with “sufficient space to fill the wall”",
"cladded with “insufficient space to fill the wall”"
"cladded with \u201csufficient space to fill the wall\u201d",
"cladded with \u201cinsufficient space to fill the wall\u201d"
]
},
"public.inspections_insulation_material": {
@ -6495,8 +6507,8 @@
"schema": "public",
"values": [
"no render",
"rendered with “insufficient” space between dpc and render",
"rendered with “sufficient” space between dpc and render"
"rendered with \u201cinsufficient\u201d space between dpc and render",
"rendered with \u201csufficient\u201d space between dpc and render"
]
},
"public.inspections_roof_orientation": {
@ -6850,13 +6862,13 @@
"schema": "public",
"values": [
"1",
"25",
"620",
"2\u20135",
"6\u201320",
"21+",
"150",
"51100",
"101300",
"3011000",
"1\u201350",
"51\u2013100",
"101\u2013300",
"301\u20131000",
"1000+"
]
},

View file

@ -1,6 +1,6 @@
{
"id": "351c4142-1926-4103-b56a-33f8530eafef",
"prevId": "eed32c53-4a51-451e-9898-5b2bd962bae7",
"id": "864ae05a-4a5c-4f29-bdfa-06e5a6c71c72",
"prevId": "a8317c01-6d20-4c67-80a6-69cad2c062ec",
"version": "7",
"dialect": "postgresql",
"tables": {
@ -800,6 +800,54 @@
"primaryKey": false,
"notNull": false
},
"batch": {
"name": "batch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"block_reference": {
"name": "block_reference",
"type": "text",
"primaryKey": false,
"notNull": false
},
"epc_prn": {
"name": "epc_prn",
"type": "text",
"primaryKey": false,
"notNull": false
},
"potential_post_sap_score_dropdown": {
"name": "potential_post_sap_score_dropdown",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ei_score": {
"name": "ei_score",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ei_score__potential_": {
"name": "ei_score__potential_",
"type": "text",
"primaryKey": false,
"notNull": false
},
"epc_sap_score": {
"name": "epc_sap_score",
"type": "text",
"primaryKey": false,
"notNull": false
},
"epc_sap_score__potential_": {
"name": "epc_sap_score__potential_",
"type": "text",
"primaryKey": false,
"notNull": false
},
"confirmed_survey_date": {
"name": "confirmed_survey_date",
"type": "timestamp (6) with time zone",
@ -3041,6 +3089,24 @@
"primaryKey": false,
"notNull": false
},
"user_inputted_address": {
"name": "user_inputted_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_inputted_postcode": {
"name": "user_inputted_postcode",
"type": "text",
"primaryKey": false,
"notNull": false
},
"lexiscore": {
"name": "lexiscore",
"type": "real",
"primaryKey": false,
"notNull": false
},
"has_pre_condition_report": {
"name": "has_pre_condition_report",
"type": "boolean",
@ -4988,6 +5054,13 @@
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'removal'"
},
"status": {
"name": "status",
"type": "text",
@ -5019,6 +5092,12 @@
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"original_batch": {
"name": "original_batch",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1282,6 +1282,160 @@
"when": 1776699608018,
"tag": "0182_messy_calypso",
"breakpoints": true
},
{
"idx": 183,
"version": "7",
"when": 1776947867497,
"tag": "0183_careless_darkhawk",
"breakpoints": true
},
{
"idx": 184,
"version": "7",
"when": 1776962910255,
"tag": "0184_tiny_annihilus",
"breakpoints": true
},
{
"idx": 185,
"version": "7",
"when": 1777026653433,
"tag": "0185_slimy_mindworm",
"breakpoints": true
},
{
"idx": 186,
"version": "7",
"when": 1777028605680,
"tag": "0186_equal_baron_zemo",
"breakpoints": true
},
{
"idx": 187,
"version": "7",
"when": 1777307158192,
"tag": "0187_mean_salo",
"breakpoints": true
},
{
"idx": 188,
"version": "7",
"when": 1777364161220,
"tag": "0188_wild_morph",
"breakpoints": true
},
{
"idx": 189,
"version": "7",
"when": 1777392468614,
"tag": "0189_high_leech",
"breakpoints": true
},
{
"idx": 190,
"version": "7",
"when": 1777392681924,
"tag": "0190_worried_drax",
"breakpoints": true
},
{
"idx": 191,
"version": "7",
"when": 1777560763716,
"tag": "0191_soft_ezekiel_stane",
"breakpoints": true
},
{
"idx": 192,
"version": "7",
"when": 1777576507360,
"tag": "0192_colorful_quasimodo",
"breakpoints": true
},
{
"idx": 193,
"version": "7",
"when": 1777750000000,
"tag": "0193_domna_survey_type",
"breakpoints": true
},
{
"idx": 194,
"version": "7",
"when": 1778100000000,
"tag": "0194_user_defined_deal_measures",
"breakpoints": true
},
{
"idx": 195,
"version": "7",
"when": 1778078457355,
"tag": "0195_jittery_harry_osborn",
"breakpoints": true
},
{
"idx": 196,
"version": "7",
"when": 1778144686568,
"tag": "0196_fantastic_bastion",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1778157212969,
"tag": "0197_bumpy_firebird",
"breakpoints": true
},
{
"idx": 198,
"version": "7",
"when": 1778176651140,
"tag": "0198_panoramic_triathlon",
"breakpoints": true
},
{
"idx": 199,
"version": "7",
"when": 1778249728162,
"tag": "0199_rich_mandroid",
"breakpoints": true
},
{
"idx": 200,
"version": "7",
"when": 1778600522440,
"tag": "0200_new_beast",
"breakpoints": true
},
{
"idx": 201,
"version": "7",
"when": 1778602691863,
"tag": "0201_known_sebastian_shaw",
"breakpoints": true
},
{
"idx": 202,
"version": "7",
"when": 1778666596489,
"tag": "0202_furry_mister_sinister",
"breakpoints": true
},
{
"idx": 203,
"version": "7",
"when": 1778680271996,
"tag": "0203_kind_spyke",
"breakpoints": true
},
{
"idx": 204,
"version": "7",
"when": 1779092260406,
"tag": "0204_bizarre_black_bird",
"breakpoints": true
}
]
}

View file

@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, boolean } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
export const hubspotDealData = pgTable("hubspot_deal_data", {
@ -58,6 +58,18 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
confirmedSurveyTime: text("confirmed_survey_time"),
surveyedDate: timestamp("surveyed_date", { precision: 6, withTimezone: true }),
surveyType: text("survey_type"),
measuresForPibiOrdered: text("measures_for_pibi_ordered"),
pibiOrderDate: timestamp("pibi_order_date", { precision: 6, withTimezone: true }),
pibiCompletedDate: timestamp("pibi_completed_date", { precision: 6, withTimezone: true }),
propertyHaltedDate: timestamp("property_halted_date", { precision: 6, withTimezone: true }),
propertyHaltedReason: text("property_halted_reason"),
technicalApprovedMeasuresForInstall: text("technical_approved_measures_for_install"),
sentToInstallerForPricing: timestamp("sent_to_installer_for_pricing", { precision: 6, withTimezone: true }),
domnasurveyRequired: boolean("domna_survey_required"),
domnaSurveyType: text("domna_survey_type"),
domnaSurveyDate: timestamp("domna_survey_date", { precision: 6, withTimezone: true }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),

View file

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

View file

@ -0,0 +1,14 @@
import { pgTable, bigserial, bigint, text, real } from "drizzle-orm/pg-core";
import { magicPlanRoom } from "./room";
export const magicPlanDoor = pgTable(
"magic_plan_door",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
roomId: bigint("magic_plan_room_id", { mode: "bigint" })
.notNull()
.references(() => magicPlanRoom.id),
widthMm: real("width_mm"),
type: text("type"),
},
);

View file

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

View file

@ -0,0 +1,16 @@
import { pgTable, bigserial, text, bigint } from "drizzle-orm/pg-core";
import { uploadedFiles } from "../uploaded_files";
export const magicPlanPlan = pgTable(
"magic_plan_plan",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
name: text("name"),
address: text("address"),
postcode: text("postcode"),
magicPlanUid: text("magic_plan_uid").unique(),
uploadedFileId: bigint("uploaded_file_id", { mode: "bigint" })
.unique()
.references(() => uploadedFiles.id),
},
);

View file

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

Some files were not shown because too many files have changed in this diff Show more