mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #273 from Hestia-Homes/main
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Dev deployment!
This commit is contained in:
commit
e67d3443b9
190 changed files with 199319 additions and 1953 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
npm install;
|
||||
11
.github/workflows/nextjs-build.yml
vendored
11
.github/workflows/nextjs-build.yml
vendored
|
|
@ -1,11 +1,12 @@
|
|||
name: Next.js Build Check
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**" # all branches
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
|
@ -21,5 +22,5 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Next.js app
|
||||
run: npm run build
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -37,3 +37,7 @@ cypress.env.json
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
backlog/**
|
||||
|
||||
docs/adr/**
|
||||
|
|
|
|||
16
CLAUDE.md
Normal file
16
CLAUDE.md
Normal 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
73
CONTEXT.md
Normal 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.
|
||||
102
cypress/e2e/live-tracking/domna-survey.cy.js
Normal file
102
cypress/e2e/live-tracking/domna-survey.cy.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Live Tracking — Domna Survey editor (issue #256)
|
||||
*
|
||||
* Verifies the approver flow on the Domna section of the property detail
|
||||
* drawer:
|
||||
* 1. an approver can set a Domna survey type (free text) and date and
|
||||
* save them,
|
||||
* 2. the drawer reflects the saved values immediately (optimistic
|
||||
* update),
|
||||
* 3. the values persist across a page reload (i.e. the deal-properties
|
||||
* endpoint wrote them server-side).
|
||||
*
|
||||
* Mirrors `halted-state.cy.js`. Assumes an authenticated approver session
|
||||
* is reusable by the test harness; the target portfolio + a deal whose
|
||||
* Domna section is editable by the current user are read from Cypress
|
||||
* env vars so the spec stays portable.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_DOMNA_DEAL_NAME");
|
||||
|
||||
const SURVEY_TYPE = "Standard";
|
||||
const SURVEY_DATE = "2025-07-15";
|
||||
|
||||
describe("Domna survey editor — approver flow", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawerForTargetDeal() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
// Switch to the Measures tab — the easiest way into the drawer.
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
if (TARGET_DEAL_NAME) {
|
||||
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
|
||||
} else {
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
}
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
// Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click).
|
||||
cy.get("[data-testid=drawer-tab-survey-admin]").click();
|
||||
cy.get("[data-testid=drawer-section-domna]").should("exist");
|
||||
}
|
||||
|
||||
it("lets an approver set domna survey type + date and persists them across reload", () => {
|
||||
openDrawerForTargetDeal();
|
||||
|
||||
// Approver sees editable inputs.
|
||||
cy.get("[data-testid=domna-survey-type-input]").should("be.visible");
|
||||
cy.get("[data-testid=domna-survey-date-input]").should("be.visible");
|
||||
|
||||
cy.get("[data-testid=domna-survey-type-input]")
|
||||
.clear()
|
||||
.type(SURVEY_TYPE);
|
||||
cy.get("[data-testid=domna-survey-date-input]")
|
||||
.clear()
|
||||
.type(SURVEY_DATE);
|
||||
|
||||
cy.get("[data-testid=domna-save-button]")
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
// Save completes — button label flips back, no error banner.
|
||||
cy.get("[data-testid=domna-save-button]").should(
|
||||
"contain.text",
|
||||
"Save Domna Survey",
|
||||
);
|
||||
cy.get("[data-testid=domna-error]").should("not.exist");
|
||||
|
||||
// Optimistic update — the inputs already reflect the new values.
|
||||
cy.get("[data-testid=domna-survey-type-input]").should(
|
||||
"have.value",
|
||||
SURVEY_TYPE,
|
||||
);
|
||||
cy.get("[data-testid=domna-survey-date-input]").should(
|
||||
"have.value",
|
||||
SURVEY_DATE,
|
||||
);
|
||||
|
||||
// Reload the page and reopen the drawer — the persisted values must
|
||||
// still be there.
|
||||
cy.reload();
|
||||
openDrawerForTargetDeal();
|
||||
|
||||
cy.get("[data-testid=domna-survey-type-input]").should(
|
||||
"have.value",
|
||||
SURVEY_TYPE,
|
||||
);
|
||||
cy.get("[data-testid=domna-survey-date-input]").should(
|
||||
"have.value",
|
||||
SURVEY_DATE,
|
||||
);
|
||||
});
|
||||
});
|
||||
109
cypress/e2e/live-tracking/halted-state.cy.js
Normal file
109
cypress/e2e/live-tracking/halted-state.cy.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Live Tracking — Halted state editor (issue #255)
|
||||
*
|
||||
* Verifies the approver flow on the Halted section of the property detail
|
||||
* drawer:
|
||||
* 1. an approver can set a halted date + free-text reason and save them,
|
||||
* 2. the drawer reflects the halted state (badge + persisted values),
|
||||
* 3. clicking Resume clears the date but keeps the reason as the
|
||||
* last-set value, both in the input and after a reload.
|
||||
*
|
||||
* Mirrors `pibi-dates.cy.js`. Assumes an authenticated approver session
|
||||
* is reusable by the test harness; the target portfolio + a deal whose
|
||||
* Halted section is editable by the current user are read from Cypress
|
||||
* env vars so the spec stays portable.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_HALTED_DEAL_NAME");
|
||||
|
||||
const HALTED_DATE = "2025-06-01";
|
||||
const HALTED_REASON = "Awaiting roof access from landlord";
|
||||
|
||||
describe("Halted state editor — approver flow", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawerForTargetDeal() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
// Switch to the Measures tab — the easiest way into the drawer.
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
if (TARGET_DEAL_NAME) {
|
||||
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
|
||||
} else {
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
}
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
// Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click).
|
||||
cy.get("[data-testid=drawer-tab-survey-admin]").click();
|
||||
cy.get("[data-testid=drawer-section-halted]").should("exist");
|
||||
}
|
||||
|
||||
it("lets an approver halt a property and resume it while preserving the reason", () => {
|
||||
openDrawerForTargetDeal();
|
||||
|
||||
// Approver sees editable inputs.
|
||||
cy.get("[data-testid=halted-date-input]").should("be.visible");
|
||||
cy.get("[data-testid=halted-reason-input]").should("be.visible");
|
||||
|
||||
// Set halted date + reason.
|
||||
cy.get("[data-testid=halted-date-input]").clear().type(HALTED_DATE);
|
||||
cy.get("[data-testid=halted-reason-input]")
|
||||
.clear()
|
||||
.type(HALTED_REASON);
|
||||
|
||||
cy.get("[data-testid=halted-save-button]")
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
// Save completes — button label flips back, no error banner.
|
||||
cy.get("[data-testid=halted-save-button]").should(
|
||||
"contain.text",
|
||||
"Save Halted State",
|
||||
);
|
||||
cy.get("[data-testid=halted-error]").should("not.exist");
|
||||
|
||||
// Drawer reflects halted state via the status badge + persisted values.
|
||||
cy.get("[data-testid=halted-status-badge]").should("contain.text", "Halted");
|
||||
cy.get("[data-testid=halted-date-input]").should("have.value", HALTED_DATE);
|
||||
cy.get("[data-testid=halted-reason-input]").should(
|
||||
"have.value",
|
||||
HALTED_REASON,
|
||||
);
|
||||
|
||||
// Now resume — date clears, reason stays.
|
||||
cy.get("[data-testid=halted-resume-button]")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
// Once resumed the badge + resume button disappear, but the reason is
|
||||
// still visible in the textarea.
|
||||
cy.get("[data-testid=halted-status-badge]").should("not.exist");
|
||||
cy.get("[data-testid=halted-resume-button]").should("not.exist");
|
||||
cy.get("[data-testid=halted-date-input]").should("have.value", "");
|
||||
cy.get("[data-testid=halted-reason-input]").should(
|
||||
"have.value",
|
||||
HALTED_REASON,
|
||||
);
|
||||
|
||||
// Reload the page — the cleared date and preserved reason persist
|
||||
// server-side.
|
||||
cy.reload();
|
||||
openDrawerForTargetDeal();
|
||||
|
||||
cy.get("[data-testid=halted-date-input]").should("have.value", "");
|
||||
cy.get("[data-testid=halted-reason-input]").should(
|
||||
"have.value",
|
||||
HALTED_REASON,
|
||||
);
|
||||
});
|
||||
});
|
||||
93
cypress/e2e/live-tracking/instruct-measure.cy.js
Normal file
93
cypress/e2e/live-tracking/instruct-measure.cy.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Live Tracking — Instruct measure flow (issue #253)
|
||||
*
|
||||
* Verifies the approver flow for instructing a measure that the
|
||||
* coordinator did not propose:
|
||||
* 1. the approver opens the property drawer at the Measures section,
|
||||
* 2. picks a measure from the canonical catalogue dropdown and submits,
|
||||
* 3. the drawer reflects the new instructed measure (optimistic chip),
|
||||
* 4. the POST hits the instructed-measures route which pushes
|
||||
* `instructed_measures` back to HubSpot,
|
||||
* 5. the approval log surface shows a row for the new approval.
|
||||
*
|
||||
* Mirrors `halted-state.cy.js` / `domna-survey.cy.js`. The spec uses
|
||||
* `cy.intercept` so the HubSpot push side-effect is observable without a
|
||||
* real CRM round-trip.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_INSTRUCT_DEAL_NAME");
|
||||
const INSTRUCT_MEASURE = "Loft insulation";
|
||||
|
||||
describe("Instruct measure — approver flow", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawerForTargetDeal() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
// Switch to the Measures tab — the easiest way into the drawer at the
|
||||
// Measures section.
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
if (TARGET_DEAL_NAME) {
|
||||
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
|
||||
} else {
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
}
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-section-measures]").should("exist");
|
||||
}
|
||||
|
||||
it("lets an approver instruct a measure and reflects it in the drawer + approval log", () => {
|
||||
// Capture the API call so we can assert the payload that would be
|
||||
// pushed to HubSpot under `instructed_measures`.
|
||||
cy.intercept(
|
||||
"POST",
|
||||
`/api/portfolio/*/instructed-measures`,
|
||||
).as("instructMeasure");
|
||||
|
||||
openDrawerForTargetDeal();
|
||||
|
||||
// Approver-only form is visible at the bottom of the Measures section.
|
||||
cy.get("[data-testid=instruct-measure-select]").should("be.visible");
|
||||
|
||||
cy.get("[data-testid=instruct-measure-select]").select(INSTRUCT_MEASURE);
|
||||
cy.get("[data-testid=instruct-measure-submit]")
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
// Wait for the POST to land and assert the body shape that the
|
||||
// service uses to drive the HubSpot push.
|
||||
cy.wait("@instructMeasure").then((intercepted) => {
|
||||
expect(intercepted.request.body).to.deep.include({
|
||||
measureName: INSTRUCT_MEASURE,
|
||||
});
|
||||
// Response from our route signals the HubSpot sync outcome — it is
|
||||
// either "ok" (mock recorded the push) or "failed" (network error).
|
||||
// We accept either here so the spec stays portable across envs.
|
||||
expect(intercepted.response.statusCode).to.be.oneOf([200, 201]);
|
||||
expect(intercepted.response.body).to.have.property("ok", true);
|
||||
expect(intercepted.response.body).to.have.property("hubspotSync");
|
||||
});
|
||||
|
||||
// Drawer reflects the instructed measure as an optimistic chip.
|
||||
cy.get("[data-testid=instructed-measures-list]").should("be.visible");
|
||||
cy.get("[data-testid=instructed-measure-chip]")
|
||||
.should("contain.text", INSTRUCT_MEASURE);
|
||||
|
||||
// No error banner.
|
||||
cy.get("[data-testid=instruct-measure-error]").should("not.exist");
|
||||
|
||||
// Approval log section reveals the new approval row when expanded.
|
||||
cy.contains("Approval Log").click();
|
||||
cy.contains(INSTRUCT_MEASURE).should("exist");
|
||||
});
|
||||
});
|
||||
172
cypress/e2e/live-tracking/measure-approval-drawer.cy.js
Normal file
172
cypress/e2e/live-tracking/measure-approval-drawer.cy.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Live Tracking — Measure approval drawer (Works tab)
|
||||
*
|
||||
* Verifies the approver flow for approving/unapproving proposed measures
|
||||
* directly from the Works tab of the PropertyDetailDrawer:
|
||||
* 1. The approver opens the Works tab and sees measure chips.
|
||||
* 2. The approver toggles a measure, clicks "Review & Save".
|
||||
* 3. The ApprovalConfirmDialog appears — the user types "approve".
|
||||
* 4. POST fires to /api/portfolio/*/approvals with the correct payload.
|
||||
*
|
||||
* Mirrors the same structure as `pibi-measures.cy.js`.
|
||||
* Uses `cy.intercept` to observe the API call without a real CRM round-trip.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_APPROVAL_DEAL_NAME");
|
||||
|
||||
describe("Measure approval drawer — Works tab approver flow", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawerAtWorksTab() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
// Open a property row from the Measures table to get the detail drawer.
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
if (TARGET_DEAL_NAME) {
|
||||
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
|
||||
} else {
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
}
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
|
||||
// The drawer opens on the Works tab from a Measures row click — verify.
|
||||
cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-section-measures]").should("exist");
|
||||
}
|
||||
|
||||
it("fetches approved measures and shows chips in the Works tab for approvers", () => {
|
||||
// Stub the GET so we control the initial state.
|
||||
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
|
||||
body: {
|
||||
pibiMeasures: [],
|
||||
approvedMeasures: ["ASHP", "Solar PV"],
|
||||
instructedMeasures: [],
|
||||
},
|
||||
}).as("getMeasures");
|
||||
|
||||
openDrawerAtWorksTab();
|
||||
|
||||
cy.wait("@getMeasures");
|
||||
|
||||
// Chip container should be visible.
|
||||
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
|
||||
|
||||
// ASHP and Solar PV should be shown as approved (checked).
|
||||
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked");
|
||||
cy.get("[data-testid='measure-approval-checkbox-Solar PV']").should(
|
||||
"be.checked",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets an approver toggle a measure and POST the approval change", () => {
|
||||
// Stub GET to return ASHP as already approved.
|
||||
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
|
||||
body: {
|
||||
pibiMeasures: [],
|
||||
approvedMeasures: ["ASHP"],
|
||||
instructedMeasures: [],
|
||||
},
|
||||
}).as("getMeasures");
|
||||
|
||||
// Intercept the POST so we can assert the payload.
|
||||
cy.intercept("POST", `/api/portfolio/*/approvals`, {
|
||||
body: { success: true },
|
||||
}).as("postApprovals");
|
||||
|
||||
openDrawerAtWorksTab();
|
||||
|
||||
cy.wait("@getMeasures");
|
||||
|
||||
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
|
||||
|
||||
// ASHP should start approved.
|
||||
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked");
|
||||
|
||||
// Toggle ASHP off (unapprove it).
|
||||
cy.get("[data-testid=measure-approval-chip-ASHP]").click();
|
||||
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should(
|
||||
"not.be.checked",
|
||||
);
|
||||
|
||||
// "Review & Save" button should now be active.
|
||||
cy.get("[data-testid=measure-approval-save]").should("not.be.disabled");
|
||||
cy.get("[data-testid=measure-approval-save]").click();
|
||||
|
||||
// ApprovalConfirmDialog should be visible — type the confirm word.
|
||||
cy.contains("Confirm approval changes").should("be.visible");
|
||||
cy.get("input[placeholder*=\"approve\"]").type("approve");
|
||||
cy.contains("button", "Confirm").click();
|
||||
|
||||
// Wait for the POST and assert the payload.
|
||||
cy.wait("@postApprovals").then((intercepted) => {
|
||||
expect(intercepted.request.body).to.have.property("changes");
|
||||
const changes = intercepted.request.body.changes;
|
||||
// Should contain one change: ASHP unapproved.
|
||||
const ashpChange = changes.find((c) => c.measureName === "ASHP");
|
||||
expect(ashpChange).to.exist;
|
||||
expect(ashpChange.approved).to.equal(false);
|
||||
});
|
||||
|
||||
// No error banner visible.
|
||||
cy.get("[data-testid=measure-approval-error]").should("not.exist");
|
||||
});
|
||||
|
||||
it("lets an approver approve a new measure and POST with correct payload", () => {
|
||||
// Stub GET — no measures approved yet.
|
||||
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
|
||||
body: {
|
||||
pibiMeasures: [],
|
||||
approvedMeasures: [],
|
||||
instructedMeasures: [],
|
||||
},
|
||||
}).as("getMeasures");
|
||||
|
||||
// Intercept POST.
|
||||
cy.intercept("POST", `/api/portfolio/*/approvals`, {
|
||||
body: { success: true },
|
||||
}).as("postApprovals");
|
||||
|
||||
openDrawerAtWorksTab();
|
||||
|
||||
cy.wait("@getMeasures");
|
||||
|
||||
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
|
||||
|
||||
// Click the first chip to approve it.
|
||||
cy.get("[data-testid=measure-approval-chips]")
|
||||
.find("label")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Save button should be active.
|
||||
cy.get("[data-testid=measure-approval-save]").should("not.be.disabled");
|
||||
cy.get("[data-testid=measure-approval-save]").click();
|
||||
|
||||
// Confirm dialog — type the word.
|
||||
cy.contains("Confirm approval changes").should("be.visible");
|
||||
cy.get("input[placeholder*=\"approve\"]").type("approve");
|
||||
cy.contains("button", "Confirm").click();
|
||||
|
||||
cy.wait("@postApprovals").then((intercepted) => {
|
||||
expect(intercepted.request.body).to.have.property("changes");
|
||||
const changes = intercepted.request.body.changes;
|
||||
// Should have at least one approval.
|
||||
expect(changes.length).to.be.greaterThan(0);
|
||||
expect(changes[0]).to.have.property("approved", true);
|
||||
expect(changes[0]).to.have.property("hubspotDealId");
|
||||
});
|
||||
|
||||
// No error banner.
|
||||
cy.get("[data-testid=measure-approval-error]").should("not.exist");
|
||||
});
|
||||
});
|
||||
286
cypress/e2e/live-tracking/pibi-section.cy.js
Normal file
286
cypress/e2e/live-tracking/pibi-section.cy.js
Normal 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");
|
||||
});
|
||||
});
|
||||
50
cypress/e2e/live-tracking/property-deal-page.cy.js
Normal file
50
cypress/e2e/live-tracking/property-deal-page.cy.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Live Tracking — Property Deal Page (replaces property-detail-drawer)
|
||||
*
|
||||
* Verifies the two core navigation behaviors after moving from a right-side
|
||||
* drawer to a dedicated CRM-style deal page at /live/[dealId]:
|
||||
*
|
||||
* 1. Property table rows link to the correct deal page URL.
|
||||
* 2. The deal page loads with the Works tab active by default.
|
||||
*
|
||||
* Reads LIVE_PORTFOLIO_SLUG from Cypress env so it runs against any seeded
|
||||
* environment without hard-coding an ID.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
|
||||
describe("Property deal page", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("property table row has link to deal page URL", () => {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
cy.contains("button, [role=tab]", "Properties").click();
|
||||
cy.get("[data-testid=property-row-link]")
|
||||
.first()
|
||||
.should("have.attr", "href")
|
||||
.and(
|
||||
"match",
|
||||
new RegExp(
|
||||
`/portfolio/${PORTFOLIO_SLUG}/your-projects/live/[^/]+$`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("deal page shows Works tab active by default", () => {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
cy.contains("button, [role=tab]", "Properties").click();
|
||||
cy.get("[data-testid=property-row-link]").first().click();
|
||||
cy.get("[data-testid=deal-page-tab-works]").should(
|
||||
"have.attr",
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
83
cypress/e2e/live-tracking/survey-request.cy.js
Normal file
83
cypress/e2e/live-tracking/survey-request.cy.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Live Tracking — Survey request flow
|
||||
*
|
||||
* Verifies the client-facing "Request Survey" flow in the Survey & Admin tab:
|
||||
* 1. User opens a property and navigates to Survey & Admin tab.
|
||||
* 2. User fills in a free-text reason and submits the survey request.
|
||||
* 3. The POST hits /api/portfolio/[id]/survey-requests.
|
||||
* 4. The drawer reflects the pending request (badge shown).
|
||||
* 5. On reload the pending request is still visible.
|
||||
*
|
||||
* Uses cy.intercept so the HubSpot side-effect is observable without a live
|
||||
* CRM round-trip.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_SURVEY_REQUEST_DEAL_NAME");
|
||||
|
||||
const SURVEY_NOTES = "Please arrange a full retrofit assessment — tenant has moved in.";
|
||||
|
||||
describe("Survey request — write user flow", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawerAtSurveyAdmin() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
if (TARGET_DEAL_NAME) {
|
||||
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
|
||||
} else {
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
}
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-tab-survey-admin]").click();
|
||||
cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible");
|
||||
}
|
||||
|
||||
it("shows the survey request form in the Survey & Admin tab", () => {
|
||||
openDrawerAtSurveyAdmin();
|
||||
|
||||
cy.get("[data-testid=survey-request-form]").should("be.visible");
|
||||
cy.get("[data-testid=survey-request-notes]").should("be.visible");
|
||||
cy.get("[data-testid=survey-request-submit]").should("be.visible");
|
||||
});
|
||||
|
||||
it("submits a survey request and shows a pending badge", () => {
|
||||
cy.intercept(
|
||||
"POST",
|
||||
`/api/portfolio/*/survey-requests`,
|
||||
).as("createSurveyRequest");
|
||||
|
||||
openDrawerAtSurveyAdmin();
|
||||
|
||||
cy.get("[data-testid=survey-request-notes]").type(SURVEY_NOTES);
|
||||
cy.get("[data-testid=survey-request-submit]")
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
cy.wait("@createSurveyRequest").then((intercepted) => {
|
||||
expect(intercepted.request.body).to.have.property("notes");
|
||||
expect(intercepted.response.statusCode).to.be.oneOf([200, 201]);
|
||||
expect(intercepted.response.body).to.have.property("ok", true);
|
||||
});
|
||||
|
||||
// Pending badge appears after submission.
|
||||
cy.get("[data-testid=survey-request-pending-badge]").should("be.visible");
|
||||
});
|
||||
|
||||
it("persists the pending request across a page reload", () => {
|
||||
openDrawerAtSurveyAdmin();
|
||||
|
||||
// If a pending request exists it should be visible without submitting again.
|
||||
cy.get("[data-testid=survey-request-pending-badge]").should("be.visible");
|
||||
});
|
||||
});
|
||||
91
cypress/e2e/live-tracking/tabbed-drawer.cy.js
Normal file
91
cypress/e2e/live-tracking/tabbed-drawer.cy.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Live Tracking — Tabbed property detail drawer
|
||||
*
|
||||
* Verifies the four-tab drawer structure introduced in the UI redesign:
|
||||
* Overview | Works | PIBI | Survey & Admin
|
||||
*
|
||||
* The spec opens the drawer from the Properties table (first row) and asserts
|
||||
* tab presence, default active state, and navigation between tabs.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
|
||||
describe("Property detail drawer — tabbed layout", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawer() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
}
|
||||
|
||||
it("opens with Overview tab active by default", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-overview]")
|
||||
.should("be.visible")
|
||||
.and("have.attr", "aria-selected", "true");
|
||||
|
||||
cy.get("[data-testid=drawer-tab-panel-overview]").should("be.visible");
|
||||
});
|
||||
|
||||
it("shows all four tabs", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-overview]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-tab-works]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-tab-pibi]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-tab-survey-admin]").should("be.visible");
|
||||
});
|
||||
|
||||
it("navigates to Works tab and shows measures content", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-works]").click();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible");
|
||||
// Measures section lives in Works tab
|
||||
cy.get("[data-testid=drawer-section-measures]").should("exist");
|
||||
});
|
||||
|
||||
it("navigates to PIBI tab and shows PIBI content", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-pibi]").click();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-panel-pibi]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-section-pibi]").should("exist");
|
||||
});
|
||||
|
||||
it("navigates to Survey & Admin tab and shows admin content", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-survey-admin]").click();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-section-domna]").should("exist");
|
||||
});
|
||||
|
||||
it("focusSection=pibi opens PIBI tab directly", () => {
|
||||
// This is exercised by the pibi-dates.cy.js helper that clicks the Measures
|
||||
// tab row — after the redesign those rows pass focusSection="pibi" and the
|
||||
// drawer should land on the PIBI tab, not Overview.
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
|
||||
cy.get("[data-testid=drawer-tab-works]")
|
||||
.should("have.attr", "aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
1297
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,8 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e:open": "start-server-and-test dev http://localhost:3000 \"cypress open --e2e\"",
|
||||
"test:e2e:run": "cypress run",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
|
|
@ -91,6 +93,7 @@
|
|||
"drizzle-kit": "^0.31.5",
|
||||
"eslint": "^8.57.1",
|
||||
"prettier": "^3.6.2",
|
||||
"start-server-and-test": "^2.0.0"
|
||||
"start-server-and-test": "^2.0.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
skills-lock.json
Normal file
89
skills-lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
289
src/app/api/portfolio/[portfolioId]/approvals/route.test.ts
Normal file
289
src/app/api/portfolio/[portfolioId]/approvals/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
85
src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts
Normal file
85
src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
17
src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts
Normal file
17
src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
132
src/app/api/portfolio/[portfolioId]/deal-properties/route.ts
Normal file
132
src/app/api/portfolio/[portfolioId]/deal-properties/route.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { db } from "@/app/db/db";
|
||||
import {
|
||||
portfolioCapabilities,
|
||||
portfolioUsers,
|
||||
} from "@/app/db/schema/portfolio";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { applyDealPropertyUpdate } from "@/app/lib/dealPropertyUpdate";
|
||||
|
||||
const patchSchema = z.object({
|
||||
dealId: z.string().min(1, "dealId is required"),
|
||||
fields: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/portfolio/[portfolioId]/deal-properties
|
||||
*
|
||||
* Single update path for whitelisted, role-gated fields on
|
||||
* `hubspot_deal_data`. The route is responsible for AuthN + portfolio role
|
||||
* lookup; per-field validation, permission check, DB write and HubSpot
|
||||
* push are delegated to `applyDealPropertyUpdate`.
|
||||
*
|
||||
* Body shape:
|
||||
* {
|
||||
* "dealId": "12345",
|
||||
* "fields": {
|
||||
* "pibi_order_date": "2025-03-12T00:00:00.000Z" | null,
|
||||
* "pibi_completed_date": "2025-04-02T00:00:00.000Z" | null,
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* 200 { results: { [field]: { ok: true } | { ok: false, error } },
|
||||
* hubspotSync: "ok" | "failed" | "skipped",
|
||||
* hubspotError?: string }
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await props.params;
|
||||
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { dealId, fields } = parsed.data;
|
||||
|
||||
// Look up the calling user's role on this portfolio. The service
|
||||
// enforces per-field permissions but we still need a role string to
|
||||
// pass through.
|
||||
const userRow = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, session.user.email))
|
||||
.limit(1);
|
||||
|
||||
if (!userRow[0]) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const portfolioUserRow = await db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioUsers.userId, userRow[0].id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const role = portfolioUserRow[0]?.role;
|
||||
if (!role) {
|
||||
return NextResponse.json(
|
||||
{ error: "No portfolio access" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Capabilities are orthogonal to role — used by approver-gated fields
|
||||
// (e.g. property_halted_date / _reason in issue #255).
|
||||
const capabilityRows = await db
|
||||
.select({ capability: portfolioCapabilities.capability })
|
||||
.from(portfolioCapabilities)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioCapabilities.userId, userRow[0].id),
|
||||
),
|
||||
);
|
||||
const capabilities = capabilityRows.map((r) => r.capability);
|
||||
|
||||
try {
|
||||
const outcome = await applyDealPropertyUpdate({
|
||||
dealId,
|
||||
fields,
|
||||
role,
|
||||
capabilities,
|
||||
});
|
||||
|
||||
return NextResponse.json(outcome);
|
||||
} catch (err) {
|
||||
console.error("[deal-properties PATCH]", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
148
src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts
Normal file
148
src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
181
src/app/api/portfolio/[portfolioId]/organisation/route.test.ts
Normal file
181
src/app/api/portfolio/[portfolioId]/organisation/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
231
src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts
Normal file
231
src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { db } from "@/app/db/db";
|
||||
import {
|
||||
portfolioCapabilities,
|
||||
portfolioUsers,
|
||||
} from "@/app/db/schema/portfolio";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
|
||||
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||
import { selectPibiMeasures } from "@/app/lib/selectPibiMeasures";
|
||||
|
||||
const postSchema = z.object({
|
||||
dealId: z.string().min(1, "dealId is required"),
|
||||
measureNames: z.array(z.string()).min(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/portfolio/[portfolioId]/pibi-measures?dealId=...
|
||||
*
|
||||
* Returns the current PIBI selection and approved measure names for a deal.
|
||||
* Used by the drawer's PIBI selector to pre-populate the multi-select.
|
||||
*
|
||||
* Response:
|
||||
* 200 { pibiMeasures: string[], approvedMeasures: string[], instructedMeasures: string[] }
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await props.params;
|
||||
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
|
||||
}
|
||||
|
||||
const dealId = req.nextUrl.searchParams.get("dealId");
|
||||
if (!dealId) {
|
||||
return NextResponse.json(
|
||||
{ error: "dealId query param is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const userRow = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, session.user.email))
|
||||
.limit(1);
|
||||
|
||||
if (!userRow[0]) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const portfolioUserRow = await db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioUsers.userId, userRow[0].id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!portfolioUserRow[0]?.role) {
|
||||
return NextResponse.json(
|
||||
{ error: "No portfolio access" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const [pibiRows, approvalRows, instructedRows] = await Promise.all([
|
||||
db
|
||||
.select({ measureName: userDefinedDealMeasures.measureName })
|
||||
.from(userDefinedDealMeasures)
|
||||
.where(
|
||||
and(
|
||||
eq(userDefinedDealMeasures.hubspotDealId, dealId),
|
||||
eq(userDefinedDealMeasures.source, "pibi_ordered"),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ measureName: dealMeasureApprovals.measureName })
|
||||
.from(dealMeasureApprovals)
|
||||
.where(
|
||||
and(
|
||||
eq(dealMeasureApprovals.hubspotDealId, dealId),
|
||||
eq(dealMeasureApprovals.isApproved, true),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ measureName: userDefinedDealMeasures.measureName })
|
||||
.from(userDefinedDealMeasures)
|
||||
.where(
|
||||
and(
|
||||
eq(userDefinedDealMeasures.hubspotDealId, dealId),
|
||||
eq(userDefinedDealMeasures.source, "instructed"),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
pibiMeasures: pibiRows.map((r) => r.measureName),
|
||||
approvedMeasures: approvalRows.map((r) => r.measureName),
|
||||
instructedMeasures: instructedRows.map((r) => r.measureName),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/portfolio/[portfolioId]/pibi-measures
|
||||
*
|
||||
* Approver-only endpoint that records which measures on a deal are going for
|
||||
* PIBI. The incoming `measureNames[]` is the FULL desired set — it replaces
|
||||
* any prior selection. Persists to `user_defined_deal_measures` with
|
||||
* `source = "pibi_ordered"` and pushes back to HubSpot under
|
||||
* `measures_for_pibi_ordered`. See `selectPibiMeasures` for the full
|
||||
* contract.
|
||||
*
|
||||
* Body:
|
||||
* { dealId: string, measureNames: string[] }
|
||||
*
|
||||
* Response:
|
||||
* 200 { ok: true, hubspotSync: "ok" | "failed", hubspotError? }
|
||||
* 400 { ok: false, error }
|
||||
* 401 / 403 / 404 on auth/role/user errors.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await props.params;
|
||||
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = postSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { dealId, measureNames } = parsed.data;
|
||||
|
||||
const userRow = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, session.user.email))
|
||||
.limit(1);
|
||||
|
||||
if (!userRow[0]) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Caller must be a portfolio member AND have the approver capability.
|
||||
const portfolioUserRow = await db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioUsers.userId, userRow[0].id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!portfolioUserRow[0]?.role) {
|
||||
return NextResponse.json(
|
||||
{ error: "No portfolio access" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const capabilityRows = await db
|
||||
.select({ capability: portfolioCapabilities.capability })
|
||||
.from(portfolioCapabilities)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioCapabilities.userId, userRow[0].id),
|
||||
),
|
||||
);
|
||||
const capabilities = capabilityRows.map((r) => r.capability);
|
||||
if (!capabilities.includes("approver")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Approver capability required" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await selectPibiMeasures({
|
||||
dealId,
|
||||
measureNames,
|
||||
userId: userRow[0].id,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ ok: false, error: result.error }, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
hubspotSync: result.hubspotSync,
|
||||
hubspotError: result.hubspotError,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[pibi-measures POST]", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
161
src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.ts
Normal file
161
src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
157
src/app/api/portfolio/[portfolioId]/pibi-requests/route.test.ts
Normal file
157
src/app/api/portfolio/[portfolioId]/pibi-requests/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
189
src/app/api/portfolio/[portfolioId]/pibi-requests/route.ts
Normal file
189
src/app/api/portfolio/[portfolioId]/pibi-requests/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
208
src/app/api/portfolio/[portfolioId]/survey-requests/route.ts
Normal file
208
src/app/api/portfolio/[portfolioId]/survey-requests/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
40
src/app/api/tasks/[taskId]/summary/route.ts
Normal file
40
src/app/api/tasks/[taskId]/summary/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
src/app/api/upload/bulk-addresses/confirm/route.ts
Normal file
55
src/app/api/upload/bulk-addresses/confirm/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/upload/bulk-addresses/route.ts
Normal file
36
src/app/api/upload/bulk-addresses/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
3
src/app/db/migrations/0183_careless_darkhawk.sql
Normal file
3
src/app/db/migrations/0183_careless_darkhawk.sql
Normal 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;
|
||||
261
src/app/db/migrations/0184_tiny_annihilus.sql
Normal file
261
src/app/db/migrations/0184_tiny_annihilus.sql
Normal 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");
|
||||
3
src/app/db/migrations/0185_slimy_mindworm.sql
Normal file
3
src/app/db/migrations/0185_slimy_mindworm.sql
Normal 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;
|
||||
2
src/app/db/migrations/0186_equal_baron_zemo.sql
Normal file
2
src/app/db/migrations/0186_equal_baron_zemo.sql
Normal 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;
|
||||
2
src/app/db/migrations/0187_mean_salo.sql
Normal file
2
src/app/db/migrations/0187_mean_salo.sql
Normal 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;
|
||||
1
src/app/db/migrations/0188_wild_morph.sql
Normal file
1
src/app/db/migrations/0188_wild_morph.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "epc_window" ALTER COLUMN "frame_material" DROP NOT NULL;
|
||||
2
src/app/db/migrations/0189_high_leech.sql
Normal file
2
src/app/db/migrations/0189_high_leech.sql
Normal 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;
|
||||
1
src/app/db/migrations/0190_worried_drax.sql
Normal file
1
src/app/db/migrations/0190_worried_drax.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "epc_property" ADD CONSTRAINT "epc_property_uploaded_file_id_unique" UNIQUE("uploaded_file_id");
|
||||
10
src/app/db/migrations/0191_soft_ezekiel_stane.sql
Normal file
10
src/app/db/migrations/0191_soft_ezekiel_stane.sql
Normal 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;
|
||||
7
src/app/db/migrations/0192_colorful_quasimodo.sql
Normal file
7
src/app/db/migrations/0192_colorful_quasimodo.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE "hubspot_users" (
|
||||
"hubspot_owner_id" text PRIMARY KEY NOT NULL,
|
||||
"first_name" text,
|
||||
"last_name" text,
|
||||
"email" text,
|
||||
"updated_at" timestamp (6) with time zone NOT NULL
|
||||
);
|
||||
1
src/app/db/migrations/0193_domna_survey_type.sql
Normal file
1
src/app/db/migrations/0193_domna_survey_type.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_type" text;
|
||||
16
src/app/db/migrations/0194_user_defined_deal_measures.sql
Normal file
16
src/app/db/migrations/0194_user_defined_deal_measures.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
CREATE TYPE "public"."user_defined_deal_measure_source" AS ENUM('instructed', 'pibi_ordered');--> statement-breakpoint
|
||||
CREATE TABLE "user_defined_deal_measures" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"hubspot_deal_id" text NOT NULL,
|
||||
"measure_name" text NOT NULL,
|
||||
"source" "user_defined_deal_measure_source" NOT NULL,
|
||||
"created_by_user_id" bigint NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"pushed_at" timestamp with time zone,
|
||||
"confirmed_in_hubspot_at" timestamp with time zone,
|
||||
"notes" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_defined_deal_measures" ADD CONSTRAINT "user_defined_deal_measures_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_user_defined_deal_measures_deal_id" ON "user_defined_deal_measures" USING btree ("hubspot_deal_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_user_defined_deal_measures_source" ON "user_defined_deal_measures" USING btree ("source");
|
||||
74
src/app/db/migrations/0195_jittery_harry_osborn.sql
Normal file
74
src/app/db/migrations/0195_jittery_harry_osborn.sql
Normal 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");
|
||||
16
src/app/db/migrations/0196_fantastic_bastion.sql
Normal file
16
src/app/db/migrations/0196_fantastic_bastion.sql
Normal 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");
|
||||
2
src/app/db/migrations/0197_bumpy_firebird.sql
Normal file
2
src/app/db/migrations/0197_bumpy_firebird.sql
Normal 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");
|
||||
1
src/app/db/migrations/0198_panoramic_triathlon.sql
Normal file
1
src/app/db/migrations/0198_panoramic_triathlon.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "magic_plan_plan" ADD COLUMN "magic_plan_uid" text;
|
||||
2
src/app/db/migrations/0199_rich_mandroid.sql
Normal file
2
src/app/db/migrations/0199_rich_mandroid.sql
Normal 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';
|
||||
1
src/app/db/migrations/0200_new_beast.sql
Normal file
1
src/app/db/migrations/0200_new_beast.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "magic_plan_plan" ADD CONSTRAINT "magic_plan_plan_magic_plan_uid_unique" UNIQUE("magic_plan_uid");
|
||||
1
src/app/db/migrations/0201_known_sebastian_shaw.sql
Normal file
1
src/app/db/migrations/0201_known_sebastian_shaw.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "public"."source" ADD VALUE 'hubspot_deal_id';
|
||||
3
src/app/db/migrations/0202_furry_mister_sinister.sql
Normal file
3
src/app/db/migrations/0202_furry_mister_sinister.sql
Normal 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");
|
||||
3
src/app/db/migrations/0203_kind_spyke.sql
Normal file
3
src/app/db/migrations/0203_kind_spyke.sql
Normal 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';
|
||||
1
src/app/db/migrations/0204_bizarre_black_bird.sql
Normal file
1
src/app/db/migrations/0204_bizarre_black_bird.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "public"."file_source" ADD VALUE 'coordination_hub';
|
||||
|
|
@ -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",
|
||||
"2–5",
|
||||
"6–20",
|
||||
"2\u20135",
|
||||
"6\u201320",
|
||||
"21+",
|
||||
"1–50",
|
||||
"51–100",
|
||||
"101–300",
|
||||
"301–1000",
|
||||
"1\u201350",
|
||||
"51\u2013100",
|
||||
"101\u2013300",
|
||||
"301\u20131000",
|
||||
"1000+"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
8616
src/app/db/migrations/meta/0184_snapshot.json
Normal file
8616
src/app/db/migrations/meta/0184_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8616
src/app/db/migrations/meta/0185_snapshot.json
Normal file
8616
src/app/db/migrations/meta/0185_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8616
src/app/db/migrations/meta/0186_snapshot.json
Normal file
8616
src/app/db/migrations/meta/0186_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8616
src/app/db/migrations/meta/0187_snapshot.json
Normal file
8616
src/app/db/migrations/meta/0187_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8616
src/app/db/migrations/meta/0188_snapshot.json
Normal file
8616
src/app/db/migrations/meta/0188_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8635
src/app/db/migrations/meta/0189_snapshot.json
Normal file
8635
src/app/db/migrations/meta/0189_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8643
src/app/db/migrations/meta/0190_snapshot.json
Normal file
8643
src/app/db/migrations/meta/0190_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8703
src/app/db/migrations/meta/0191_snapshot.json
Normal file
8703
src/app/db/migrations/meta/0191_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8746
src/app/db/migrations/meta/0192_snapshot.json
Normal file
8746
src/app/db/migrations/meta/0192_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8746
src/app/db/migrations/meta/0193_snapshot.json
Normal file
8746
src/app/db/migrations/meta/0193_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9254
src/app/db/migrations/meta/0195_snapshot.json
Normal file
9254
src/app/db/migrations/meta/0195_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9380
src/app/db/migrations/meta/0196_snapshot.json
Normal file
9380
src/app/db/migrations/meta/0196_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9381
src/app/db/migrations/meta/0197_snapshot.json
Normal file
9381
src/app/db/migrations/meta/0197_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9387
src/app/db/migrations/meta/0198_snapshot.json
Normal file
9387
src/app/db/migrations/meta/0198_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9389
src/app/db/migrations/meta/0199_snapshot.json
Normal file
9389
src/app/db/migrations/meta/0199_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9397
src/app/db/migrations/meta/0200_snapshot.json
Normal file
9397
src/app/db/migrations/meta/0200_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9398
src/app/db/migrations/meta/0201_snapshot.json
Normal file
9398
src/app/db/migrations/meta/0201_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9425
src/app/db/migrations/meta/0202_snapshot.json
Normal file
9425
src/app/db/migrations/meta/0202_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9428
src/app/db/migrations/meta/0203_snapshot.json
Normal file
9428
src/app/db/migrations/meta/0203_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
9429
src/app/db/migrations/meta/0204_snapshot.json
Normal file
9429
src/app/db/migrations/meta/0204_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
13
src/app/db/schema/crm/hubspot_user_table.ts
Normal file
13
src/app/db/schema/crm/hubspot_user_table.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { InferModel } from "drizzle-orm";
|
||||
|
||||
export const hubspotUsers = pgTable("hubspot_users", {
|
||||
hubspotOwnerId: text("hubspot_owner_id").primaryKey(),
|
||||
firstName: text("first_name"),
|
||||
lastName: text("last_name"),
|
||||
email: text("email"),
|
||||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true }).notNull(),
|
||||
});
|
||||
|
||||
export type HubspotUser = InferModel<typeof hubspotUsers>;
|
||||
export type NewHubspotUser = InferModel<typeof hubspotUsers, "insert">;
|
||||
14
src/app/db/schema/magic_plan/door.ts
Normal file
14
src/app/db/schema/magic_plan/door.ts
Normal 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"),
|
||||
},
|
||||
);
|
||||
13
src/app/db/schema/magic_plan/floor.ts
Normal file
13
src/app/db/schema/magic_plan/floor.ts
Normal 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"),
|
||||
},
|
||||
);
|
||||
16
src/app/db/schema/magic_plan/plan.ts
Normal file
16
src/app/db/schema/magic_plan/plan.ts
Normal 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),
|
||||
},
|
||||
);
|
||||
16
src/app/db/schema/magic_plan/room.ts
Normal file
16
src/app/db/schema/magic_plan/room.ts
Normal 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
Loading…
Add table
Reference in a new issue