diff --git a/.claude/settings.json b/.claude/settings.json index e89494b4..490e7d82 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -21,7 +21,9 @@ "Bash(npx drizzle-kit *)", "Bash(pip install *)", "Bash(terraform fmt *)", - "Bash(gh label *)" + "Bash(gh label *)", + "Bash(python3 -c \"import psycopg2\")", + "Bash(python3 -c \"import psycopg\")" ], "deny": [ "Bash(npx drizzle-kit generate)", diff --git a/.gitignore b/.gitignore index 04f701b5..237fa406 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,9 @@ docs/wip/** # Personal Claude Code settings (per-developer, not shared) .claude/settings.local.json + +# transient local scratch (dev session helpers) +.mint.cjs +.chip-preview.* +.shot.cjs +scratch-*.cjs diff --git a/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md b/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md new file mode 100644 index 00000000..73736a11 --- /dev/null +++ b/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md @@ -0,0 +1,78 @@ +--- +target: portfolio page (property table + toolbar + no-plans state) +total_score: 27 +p0_count: 1 +p1_count: 1 +timestamp: 2026-07-13T21-29-24Z +slug: rc-app-portfolio-slug-components-propertytable-tsx +--- +Method: dual-agent (A: ad10a436 design-review · B: a517f2c1 detector+evidence) + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3 | Good results count + spinner overlay, but first-load is bare "Loading properties…" (no skeleton) and a no-plans portfolio gives zero signal that modelling is the missing step. | +| 2 | Match System / Real World | 3 | "Plan Cost / No cost", "Expected EPC" read well; but blank EPC bubbles + "—" Ref communicate nothing about *why* they're empty. | +| 3 | User Control and Freedom | 3 | Clear/Clear-all on filters + selection; no undo on optimistic tag assign, no cancel-export. | +| 4 | Consistency and Standards | 2 | Two colour systems: toolbar uses brandblue/primary; filter sidebar + Edit-Columns checkboxes use raw black/bg-black/accent-black. | +| 5 | Error Prevention | 3 | Delete has a preview modal, export gated >6000 — but the delete modal has Cancel and no wired Confirm (dead-end). | +| 6 | Recognition Rather Than Recall | 3 | Columns labelled, Current EPC has a "?" tooltip; but Current/Lodged/Expected are three identical blank bubbles with no legend when empty. | +| 7 | Flexibility and Efficiency | 3 | Column visibility, quick filters, filter builder, export — strong; undercut by a 7-row page size on a desk tool. | +| 8 | Aesthetic and Minimalist Design | 2 | The no-plans grid is a wall of "—", blank bubbles, italic "No cost"; 6 top-level toolbar actions + two dark CTAs + shouty micro-labels. | +| 9 | Error Recovery | 3 | load-more + export fail silently; tag-assign error only shows inline in the popover footer. | +| 10 | Help and Documentation | 2 | One tooltip (Current EPC); no inline guidance connecting an unmodelled portfolio to "Run modelling". | +| **Total** | | **27/40** | **Acceptable — solid plumbing, dragged down by the empty state + consistency** | + +## Anti-Patterns Verdict + +**LLM assessment:** Not egregious slop — the table is competently built and restrained — but three tells betray a generated-template lineage: tiny uppercase black micro-labels everywhere (`RESULTS:` font-black uppercase, `CURATE SELECTION` at 9px), `rounded-xl` + `shadow-sm` cards the brief names as an anti-reference, and em-dash-as-content filling 5–6 of 9 columns. Bones are institutional; finish is generic-admin. + +**Deterministic scan:** detect.mjs exit 0 — **zero findings**. No gradient text, decorative gradients, side-stripe borders, glassmorphism, over-rounded radii (only rounded-lg/xl/full), heavy shadows (only shadow-sm), or arbitrary z-index. The mechanical slop tells are genuinely absent — the problems here are IA/hierarchy/contrast that a static scanner can't see, which is exactly where the design review earns its keep. + +**Visual overlays:** none — browser automation unavailable (arm64 container can't run the bundled x86 Chromium under qemu). Fallback: static grep evidence corroborated the review (8 placeholder "—" cells in propertyTableColumns.tsx: 256/336/442/451/459/470/483/494; text-slate-300 at 384/442/451/494/524; 6 toolbar controls at 602/617/658/686/693/742; no dedicated no-plans empty-state component). + +## Overall Impression + +The interaction plumbing is genuinely good (optimistic tag writes, filter-scoped selection, provenance signalling). What lets it down is a single unaddressed truth: **the default state of every brand-new portfolio — properties added, nothing modelled — is the one state with zero design investment.** A first-time housing-association user lands on 185 rows where Ref is "—", three EPC columns are blank, Expiry is "—", and every Plan Cost is a faint italic "No cost". The read isn't "fresh start", it's "this tool is broken / my import failed" — the worst possible first impression for a product whose promise is *trustworthy numbers*. The single biggest opportunity is to design the unmodelled state as a first-class surface, not a fallthrough. + +## What's Working + +1. **The Current-EPC "?" tooltip** — exactly right for the institutional register: explains a load-bearing figure inline without cluttering the header. A pattern to extend, not remove. +2. **The Predicted pill + provenance model** (amber "Predicted", ADR-0002) — genuine trust-signalling that tells analysts which EPCs to trust less; precisely the "trustworthy numbers" value the brand claims. +3. **Optimistic tag writes across every cached page + filter-scoped selection surviving pagination** — Linear/Stripe-class plumbing, invisible when it works. + +## Priority Issues + +**[P0] The no-plans / unmodelled state has no design at all.** `EmptyPropertyState` only fires when there are literally zero rows; a portfolio with 185 unmodelled properties falls straight through to the full table with 5–6 empty columns. This is the *default* state of every new portfolio and the exact case flagged as "terrible". **Fix:** detect "has properties, zero plans/EPC" and render a purpose-built view — collapse/hide the three EPC + Expiry + Plan Cost columns, show Address/Ref/Tags at full density, and surface one reassuring banner ("185 properties added — run modelling to generate EPC ratings and retrofit costs →") wired to the existing Run-modelling route. *Command: `onboard`* + +**[P1] Empty cells fail contrast and read as breakage.** `text-slate-300 italic` "No cost" and slate-300 em-dashes / "Unknown" are below WCAG AA on white and make populated data indistinguishable from missing data at a glance. Four different "empty" renderings coexist (blank `w-5 h-5` div, "—", italic "No cost", "Unknown"). **Fix:** raise empty text to slate-400/500 and pick ONE absence vocabulary. *Command: `harden`* + +**[P2] Six top-level toolbar actions with two competing dark CTAs.** Filters, Edit Columns, Export, Tags▾, Add properties▾, Run modelling in one undifferentiated row; Add properties and Run modelling are both navy `bg-primary` — no primary focus, and the brief says navy belongs "in small doses". **Fix:** one dark CTA at a time (Add properties when empty → Run modelling once populated), fold Export/Edit Columns/Tags into a quieter secondary group. *Command: `distill`* + +**[P3] Colour-system drift between toolbar and filter sidebar.** Toolbar uses brandblue/primary; `PropertyFilters.tsx` and the Edit-Columns checkboxes use raw black/bg-black/accent-black — two systems on one page. **Fix:** replace every black token with the brand navy/brandblue scale. *Command: `colorize`* + +**[P3] No first-load skeleton; silent failures.** First load is a lone "Loading properties…" string; load-more and export swallow errors (`// silently ignore`). For a data-trust product a silent export failure is a credibility risk. **Fix:** skeleton rows + a toast/inline error on export and load-more failure. *Command: `polish`* + +## Persona Red Flags + +**Alex (power user):** `pageSize: 7` on a large desk screen means paging 27× through 185 homes; lazy load-more only triggers on reaching the last page — no jump-to-end or density control. Wants 50–100 rows + persistent EPC/cost sort. + +**Sam (accessibility):** slate-300 "No cost"/"Unknown" fail AA outright; the quick-filter clear control is a `` nested *inside* a ` + ) : ( + <> + + + + )} + + + + + + Match to a UPRN + + Confirm the postcode and pick the matching address. Only residential + addresses from Ordnance Survey are shown, so the UPRN is always valid. + + + +
+
+ + setAddress(e.target.value)} + placeholder="e.g. 20 Brenchley Road" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
+
+
+ + setPostcode(e.target.value)} + placeholder="e.g. BR5 2TD" + onKeyDown={(e) => { + if (e.key === "Enter" && postcode.trim() && !searching) + runSearch(); + }} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
+ +
+ + {searchError &&

{searchError}

} + + {residential && ( +
+
+

+ Residential addresses +

+ {source === "cache" && ( + + from cache + + )} +
+
+ {residential.length === 0 ? ( +

+ No residential addresses found for that postcode. +

+ ) : ( +
    + {residential.map((c) => { + const { label } = describeCandidate(c); + const isSelected = selected?.uprn === c.uprn; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ )} +
+ + + {save.error && ( +

+ {save.error.message} +

+ )} + + +
+
+
+ + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index 586b1b1b..61046a8d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { + useAddressMatches, useBulkUploadProgress, useConfirmMultiEntryOrdering, useConfirmVerification, @@ -14,6 +15,7 @@ import { useSampleClassifications, type SampleClassifications, } from "@/lib/bulkUpload/client"; +import AddressesPanel from "./AddressesPanel"; import { partLabel, isPermutation, @@ -69,6 +71,9 @@ export default function OnboardingProgress({ }); const combine = useRequestCombine(portfolioId, uploadId); const finalize = useFinalize(portfolioId, uploadId); + const [reviewTab, setReviewTab] = useState<"classification" | "addresses">( + "classification", + ); // Read-only classifications for the multi-entry sample (issue #298). Fetched // only once a sample exists at awaiting_review. Hook stays above the early @@ -77,6 +82,13 @@ export default function OnboardingProgress({ progress.data?.upload.status === "awaiting_review" && !!progress.data.upload.multiEntrySummary?.sample; const classifications = useSampleClassifications(portfolioId, uploadId, sampleReady); + // Combiner rows address2uprn could not confidently match (ADR-0057). Fetched + // once at awaiting_review; drives the Addresses tab + the Finalise gate. + const addressMatches = useAddressMatches( + portfolioId, + uploadId, + progress.data?.upload.status === "awaiting_review", + ); if (progress.isError) return null; if (!progress.data) { @@ -139,11 +151,17 @@ export default function OnboardingProgress({ (n, descriptions) => n + descriptions.length, 0, ); + // Flagged addresses (no confident UPRN) must all be resolved before finalise, + // else a withheld row lands as an unintended no-UPRN property (ADR-0057). + const flaggedCount = addressMatches.data?.flagged.length ?? 0; + const unresolvedAddresses = addressMatches.data?.unresolved ?? 0; + const showAddressTab = isAwaitingReview && flaggedCount > 0; const canFinalize = isAwaitingReview && (!needsVerify || verifyAck) && (!needsOrdering || orderingConfirmed) && - unknownTotal === 0; + unknownTotal === 0 && + unresolvedAddresses === 0; return (
@@ -211,47 +229,94 @@ export default function OnboardingProgress({ )}
- {needsVerify && sample && ( - + {/* Two-tab review at awaiting_review: classification checks | address/UPRN + confirmation (ADR-0057). The Addresses tab only appears when there are + flagged rows; otherwise the classification panels render as before. */} + {showAddressTab && ( +
+ + +
)} - {isAwaitingReview && unknownTotal > 0 && ( - - )} - - {needsOrdering && orderingSamples.length > 0 && ( -
- {orderingSamples.map(([count, orderSample], i) => ( - + {needsVerify && sample && ( + 1 - ? `Part group ${i + 1}` - : undefined - } + verified={verifyAck} + stepLabel={showStepNumbers ? "Step 1" : undefined} portfolioId={portfolioId} uploadId={uploadId} /> - ))} -
+ )} + + {isAwaitingReview && unknownTotal > 0 && ( + + )} + + {needsOrdering && orderingSamples.length > 0 && ( +
+ {orderingSamples.map(([count, orderSample], i) => ( + 1 + ? `Part group ${i + 1}` + : undefined + } + portfolioId={portfolioId} + uploadId={uploadId} + /> + ))} +
+ )} + + )} + + {showAddressTab && reviewTab === "addresses" && ( + )} {(canRunCombiner || isAwaitingReview) && ( @@ -271,11 +336,13 @@ export default function OnboardingProgress({ isPending={finalize.isPending} disabled={!canFinalize} disabledReason={ - unknownTotal > 0 - ? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first` - : needsVerify && !verifyAck - ? "Verify the classification first" - : "Confirm the building-part order first" + unresolvedAddresses > 0 + ? `Resolve ${unresolvedAddresses} address${unresolvedAddresses === 1 ? "" : "es"} on the Addresses tab first` + : unknownTotal > 0 + ? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first` + : needsVerify && !verifyAck + ? "Verify the classification first" + : "Confirm the building-part order first" } onClick={() => finalize.mutate(undefined, { onSuccess: () => router.refresh() }) diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index 4f9b0cde..c3305755 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -11,9 +11,10 @@ export default async function Page(props: { const params = await props.params; const portfolioId = params.slug; - return ( - <> - - - ); + // UPRN matching is now confirmed during bulk-upload onboarding (ADR-0057), so + // properties arrive already matched — the portfolio-level "Unmatched" tab is + // retired. The tab components (PortfolioTabs / UnmatchedProperties / + // getUnmatchedProperties) are left in place for now, pending a follow-up that + // reconciles legacy no-UPRN properties onboarded before this feature. + return ; } diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx index 55fd7b98..a9e3101b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx @@ -7,6 +7,7 @@ import { getScenarioWithPlansCount } from "@/lib/scenarios/queries"; import { BandChip, EPC_SOFT, + FabricFirstChip, GOAL_SHORT_LABELS, GoalIcon, StatusPill, @@ -114,7 +115,7 @@ export default async function ScenarioDetailPage(props: { )} -
+
Exclusions
{summary.kind === "all" ? ( @@ -151,6 +152,21 @@ export default async function ScenarioDetailPage(props: { )}
+
+
Fabric first
+
+ {s.fabricFirst ? ( + + + + insulation, glazing & ventilation before heating & renewables + + + ) : ( + Off — measures considered together + )} +
+
diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx index b3aaea2f..ac9a9d06 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx @@ -24,6 +24,7 @@ interface ExistingScenario { goalValue: string | null; budget: number | null; exclusions: string[]; + fabricFirst: boolean; modelled: boolean; } @@ -73,6 +74,7 @@ export function NewScenarioJourney({ const [band, setBand] = useState(template?.goalValue ?? null); const [budget, setBudget] = useState(template?.budget ? String(template.budget) : ""); const [excluded, setExcluded] = useState>(new Set(template?.exclusions ?? [])); + const [fabricFirst, setFabricFirst] = useState(template?.fabricFirst ?? false); const [errors, setErrors] = useState<{ name?: string; band?: string; excl?: string }>({}); const isEpc = goal === PORTFOLIO_GOALS.EPC; @@ -90,6 +92,7 @@ export function NewScenarioJourney({ goalValue: isEpc ? band : null, budgetPerProperty: budgetNumber, exclusions: [...excluded], + fabricFirst, }), }); if (!res.ok) throw new Error((await res.json()).error ?? "Save failed"); @@ -126,6 +129,7 @@ export function NewScenarioJourney({ goalValue: isEpc ? band : null, budgetPerProperty: budgetNumber, exclusions: [...excluded], + fabricFirst, }, existing.map((s) => ({ id: BigInt(s.id), @@ -135,6 +139,7 @@ export function NewScenarioJourney({ goalValue: s.goalValue, budget: s.budget, exclusions: s.exclusions.length ? `{${s.exclusions.join(",")}}` : null, + fabricFirst: s.fabricFirst, })), ) : null; @@ -455,6 +460,51 @@ export function NewScenarioJourney({ ))} + {errors.excl &&
{errors.excl}
} )} @@ -542,6 +592,14 @@ export function NewScenarioJourney({
), ], + [ + "Fabric first", + fabricFirst ? ( + "On — fabric before heating & renewables" + ) : ( + Off — measures considered together + ), + ], ].map(([label, value]) => (
s.id === from) ?? null : null; diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx index ba1afc38..76439ac6 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx @@ -4,6 +4,7 @@ import { listScenariosWithStatus } from "@/lib/scenarios/queries"; import { BandChip, EPC_SOFT, + FabricFirstChip, GOAL_SHORT_LABELS, GoalIcon, StatusPill, @@ -139,6 +140,12 @@ export default async function ScenariosPage(props: { /property cap ) : null} + {s.fabricFirst ? ( + <> + {" "} + · + + ) : null}
diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx index ac907289..e7c73080 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx @@ -131,6 +131,19 @@ export function constraintLine(exclusions: string[]): React.ReactNode { ); } +/** Small badge marking a fabric-first Scenario. Render only when the flag is set. */ +export function FabricFirstChip({ className = "" }: { className?: string }) { + return ( + + + Fabric first + + ); +} + export const formatDate = (d: Date | string) => new Date(d).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx index db1a38bf..11eafddd 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx @@ -1,7 +1,7 @@ import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { SettingsSidebarLink } from "./SettingsSidebarLink"; -import { Settings2, Users, Building2, ScrollText } from "lucide-react"; +import { Settings2, Users, Building2, ScrollText, Tag } from "lucide-react"; export default async function SettingsLayout({ children, @@ -33,6 +33,12 @@ export default async function SettingsLayout({ > User Access + } + > + Tags + {isDomnaUser && ( (null); + const [editingId, setEditingId] = useState(null); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) }); + + const createTag = useMutation({ + mutationFn: async (input: { name: string; colour: string }) => { + const res = await fetch(`/api/portfolio/${portfolioId}/tags`, { + method: "POST", + body: JSON.stringify(input), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Failed to create tag"); + return body; + }, + onSuccess: () => { + setName(""); + setColour(DEFAULT_COLOUR); + setFormError(null); + invalidate(); + }, + onError: (e: Error) => setFormError(e.message), + }); + + return ( +
+

Tags

+

+ Group properties to run scenarios on a subset or filter your reporting. + Assign tags from the property table. +

+ + {/* Create */} +
{ + e.preventDefault(); + createTag.mutate({ name, colour }); + }} + > + + + +
+ {formError &&

{formError}

} + + {/* List */} + {isLoading ? ( +

Loading…

+ ) : tags.length === 0 ? ( +

No tags yet.

+ ) : ( +
    + {tags.map((tag) => + editingId === tag.id ? ( + { + setEditingId(null); + invalidate(); + }} + onCancel={() => setEditingId(null)} + /> + ) : ( + setEditingId(tag.id)} + onDeleted={invalidate} + /> + ), + )} +
+ )} +
+ ); +} + +function TagRow({ + portfolioId, + tag, + onEdit, + onDeleted, +}: { + portfolioId: string; + tag: Tag; + onEdit: () => void; + onDeleted: () => void; +}) { + const del = useMutation({ + mutationFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/tags/${tag.id}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete tag"); + }, + onSuccess: onDeleted, + }); + + return ( +
  • + + + {tag.propertyCount} propert{tag.propertyCount === 1 ? "y" : "ies"} + +
    + + +
    +
  • + ); +} + +function EditRow({ + portfolioId, + tag, + onDone, + onCancel, +}: { + portfolioId: string; + tag: Tag; + onDone: () => void; + onCancel: () => void; +}) { + const [name, setName] = useState(tag.name); + const [colour, setColour] = useState(tag.colour); + const [error, setError] = useState(null); + + const save = useMutation({ + mutationFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/tags/${tag.id}`, { + method: "PATCH", + body: JSON.stringify({ name, colour }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Failed to save"); + return body; + }, + onSuccess: onDone, + onError: (e: Error) => setError(e.message), + }); + + return ( +
  • + setColour(e.target.value)} + className="h-8 w-9 rounded-md border border-gray-300 bg-white p-0.5 cursor-pointer" + aria-label="Tag colour" + /> + setName(e.target.value)} + className="flex-1" + /> + {error && {error}} + + +
  • + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx new file mode 100644 index 00000000..d2042156 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx @@ -0,0 +1,12 @@ +import TagsCard from "./TagsCard"; + +export default async function TagsSettingsPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + return ( +
    + +
    + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx new file mode 100644 index 00000000..d8b174e8 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + PackageCheck, + AlertTriangle, + X, + Download, + Loader2, + CheckCircle2, +} from "lucide-react"; +import DocumentTable, { type DocumentSelection } from "./DocumentTable"; +import type { + ClassifiedDeal, + DocStatusMap, + PortfolioCapabilityType, + ApprovalsByDeal, +} from "./types"; +import { + MAX_BULK_DOWNLOAD_PROPERTIES, + buildSelectionConfig, + bulkDownloadRefetchInterval, + type BulkDownloadStatus, +} from "@/lib/bulkDocumentDownload/model"; + +interface Props { + data: ClassifiedDeal[]; + onOpenDrawer: ( + dealId: string, + uprn: string | null, + landlordPropertyId: string | null, + dealname: string | null, + batch: string | null, + batchDescription: string | null, + ) => void; + docStatusMap: DocStatusMap; + portfolioId: string; + userCapability: PortfolioCapabilityType; + approvalsByDeal?: ApprovalsByDeal; + /** Current Documents-tab scope — a real project code or the "__ALL__" sentinel. */ + currentProjectCode: string; +} + +/** JSON body or null — never throws, so a 504's HTML page can't crash the UI. */ +async function parseBody(res: Response): Promise<{ error?: string; taskId?: string } | null> { + const text = await res.text().catch(() => ""); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * Bulk document download on the Documents tab: a "Bulk download" button flips the + * table into row-selection mode; the picked landlord property ids become a task + * whose ZIP is emailed (and shown here while the page stays open). Scoped by the + * tab's existing project/group selection. + */ +export default function DocumentBulkDownload({ + data, + onOpenDrawer, + docStatusMap, + portfolioId, + userCapability, + approvalsByDeal, + currentProjectCode, +}: Props) { + const [selecting, setSelecting] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [done, setDone] = useState<{ taskId: string } | null>(null); + + const landlordPropertyIds = [...selectedIds]; + const built = buildSelectionConfig({ + scope: { kind: "project", projectCode: currentProjectCode }, + includeAll: false, + landlordPropertyIds, + portfolioId: Number(portfolioId), + }); + + const trigger = useMutation<{ taskId: string }, Error, void>({ + mutationFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/bulk-document-download`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: { kind: "project", projectCode: currentProjectCode }, + includeAll: false, + landlordPropertyIds, + }), + }); + const body = await parseBody(res); + if (!res.ok) { + throw new Error( + body?.error ?? + (res.status >= 500 + ? "The server didn’t respond in time. Your download may still be preparing — check your email shortly." + : `Couldn’t start the download (${res.status}).`), + ); + } + if (!body?.taskId) throw new Error("Unexpected response from the server."); + return { taskId: body.taskId }; + }, + onSuccess: ({ taskId }) => { + setDone({ taskId }); + setSelecting(false); + setSelectedIds(new Set()); + }, + }); + + const status = useQuery({ + queryKey: ["bulk-download-status", portfolioId, done?.taskId], + enabled: !!done, + refetchInterval: (d) => bulkDownloadRefetchInterval(d), + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-document-download/${done!.taskId}`, + ); + const body = await parseBody(res); + if (!res.ok || !body) { + throw new Error("Couldn’t check the download status."); + } + return body as unknown as BulkDownloadStatus; + }, + }); + + const selection: DocumentSelection | undefined = selecting + ? { + selectedIds, + onToggle: (id) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }), + onSetMany: (ids, on) => + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of ids) { + if (on) next.add(id); + else next.delete(id); + } + return next; + }), + } + : undefined; + + const overCap = selectedIds.size > MAX_BULK_DOWNLOAD_PROPERTIES; + + return ( +
    + {/* Delivery / status card */} + {done && ( + setDone(null)} + /> + )} + + {/* Bulk-download control bar */} + {!selecting ? ( +
    + +
    + ) : ( +
    + + {selectedIds.size} selected + + + Tick properties, or use the header box to select the page (then “select + all across pages”). + + {overCap && ( + + Over the {MAX_BULK_DOWNLOAD_PROPERTIES} limit — narrow your selection. + + )} + {trigger.isError && ( + {trigger.error.message} + )} +
    + + +
    +
    + )} + + +
    + ); +} + +function DeliveryCard({ + status, + isError, + onDismiss, +}: { + status: BulkDownloadStatus | undefined; + isError: boolean; + onDismiss: () => void; +}) { + const [showSkipped, setShowSkipped] = useState(false); + const state = status?.state ?? "preparing"; + + // Completed, but the link couldn't be surfaced in-app (unparseable outputs). + // The worker still emails it — send the user there rather than hang. + if (state === "ready" && !status?.delivery) { + return ( +
    + +
    +

    Your download is ready

    +

    + We’ve emailed you the link — it’s valid for about 60 minutes. +

    +
    + +
    + ); + } + + if (state === "ready" && status?.delivery) { + const { presignedUrl, included, skipped } = status.delivery; + return ( +
    +
    + +
    +

    Your download is ready

    +

    + {included} {included === 1 ? "document" : "documents"} included + {skipped.length > 0 && ` · ${skipped.length} skipped`}. We’ve also emailed + the link — it’s valid for about 60 minutes. +

    +
    + + + Download ZIP + +
    + {skipped.length > 0 && ( +
    + + {showSkipped && ( +
      + {skipped.map((s, i) => ( +
    • + {s.landlord_property_id} — {s.reason} +
    • + ))} +
    + )} +
    + )} +
    + ); + } + + if (state === "failed" || isError) { + return ( +
    + +
    +

    + We couldn’t build this download +

    +

    + The selection may contain no documents. Try a different selection. +

    +
    + +
    + ); + } + + return ( +
    + +
    +

    + Your download is being prepared — we’ll email you a link +

    +

    + You can leave this page; the link will be in your inbox, and it’ll appear here + if you stay. +

    +
    +
    + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx index cd09460e..ec50d043 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx @@ -10,6 +10,7 @@ import { flexRender, type SortingState, type PaginationState, + type ColumnDef, } from "@tanstack/react-table"; import { Table, @@ -34,6 +35,18 @@ import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType, ApprovalsBy type RetroAssessmentFilter = "all" | "none" | "partial" | "complete"; type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete"; +/** + * Row-selection wiring for bulk document download. When present, the table + * shows a tick column; select-all operates over every filtered row (all pages), + * not just the visible page. Rows without a landlord property id are un-tickable + * — documents are matched by landlord property id, so there is nothing to zip. + */ +export interface DocumentSelection { + selectedIds: Set; + onToggle: (landlordPropertyId: string) => void; + onSetMany: (landlordPropertyIds: string[], selected: boolean) => void; +} + interface DocumentTableProps { data: ClassifiedDeal[]; onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void; @@ -41,6 +54,7 @@ interface DocumentTableProps { portfolioId: string; userCapability: PortfolioCapabilityType; approvalsByDeal?: ApprovalsByDeal; + selection?: DocumentSelection; } function escapeCell(value: unknown): string { @@ -54,7 +68,7 @@ function escapeCell(value: unknown): string { : str; } -export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal }: DocumentTableProps) { +export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal, selection }: DocumentTableProps) { const [globalFilter, setGlobalFilter] = useState(""); const [retroAssessmentFilter, setRetroAssessmentFilter] = useState("all"); const [installStatusFilter, setInstallStatusFilter] = useState("all"); @@ -87,7 +101,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo }); }, [data, retroAssessmentFilter, installStatusFilter, docStatusMap]); - const columns = useMemo( + const baseColumns = useMemo( () => createDocumentTableColumns( onOpenDrawer, docStatusMap, @@ -96,6 +110,60 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo [onOpenDrawer, docStatusMap, userCapability], ); + // Prepend a tick column when in selection mode. Recomputed when `selection` + // changes (a fresh Set per toggle) so checkbox state stays live. + const columns: ColumnDef[] = useMemo(() => { + if (!selection) return baseColumns; + const selectColumn: ColumnDef = { + id: "select", + enableSorting: false, + enableGlobalFilter: false, + header: ({ table }) => { + // Header ticks the current page; the banner below offers all pages. + const pageIds = table + .getRowModel() + .rows.map((r) => r.original.landlordPropertyId) + .filter((id): id is string => !!id); + const allSelected = + pageIds.length > 0 && pageIds.every((id) => selection.selectedIds.has(id)); + const someSelected = pageIds.some((id) => selection.selectedIds.has(id)); + return ( + { + if (el) el.indeterminate = someSelected && !allSelected; + }} + onChange={() => selection.onSetMany(pageIds, !allSelected)} + className="h-4 w-4 accent-brandblue align-middle" + /> + ); + }, + cell: ({ row }) => { + const id = row.original.landlordPropertyId; + if (!id) { + return ( + + ); + } + return ( + selection.onToggle(id)} + className="h-4 w-4 accent-brandblue align-middle" + /> + ); + }, + }; + return [selectColumn, ...baseColumns]; + }, [baseColumns, selection]); + const table = useReactTable({ data: filteredData, columns, @@ -151,6 +219,31 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo const currentPage = table.getState().pagination.pageIndex + 1; const totalFiltered = table.getFilteredRowModel().rows.length; + // "Select all across pages" banner: shown when the whole current page is + // ticked and there are more filtered rows than fit on it (Gmail-style). + const pageSelectableIds = selection + ? table + .getRowModel() + .rows.map((r) => r.original.landlordPropertyId) + .filter((id): id is string => !!id) + : []; + const filteredSelectableIds = selection + ? table + .getFilteredRowModel() + .rows.map((r) => r.original.landlordPropertyId) + .filter((id): id is string => !!id) + : []; + const pageFullySelected = + pageSelectableIds.length > 0 && + pageSelectableIds.every((id) => selection!.selectedIds.has(id)); + const allFilteredSelected = + filteredSelectableIds.length > 0 && + filteredSelectableIds.every((id) => selection!.selectedIds.has(id)); + const showSelectAllBanner = + !!selection && + pageFullySelected && + filteredSelectableIds.length > pageSelectableIds.length; + const retroAssessmentLabel: Record = { all: "All retrofit statuses", none: "No Retrofit Docs", @@ -248,6 +341,40 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo propert{totalFiltered === 1 ? "y" : "ies"}

    + {/* Select-all-across-pages banner */} + {showSelectAllBanner && ( +
    + {allFilteredSelected ? ( + <> + + All {filteredSelectableIds.length}{" "} + matching propert{filteredSelectableIds.length === 1 ? "y is" : "ies are"}{" "} + selected. + + + + ) : ( + <> + + All {pageSelectableIds.length} on this + page selected. + + + + )} +
    + )} + {/* Table */}
    diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index b8a67a3a..1318ba79 100644 Binary files a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx and b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx differ diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx index d1e07c96..6c0e4ddf 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx @@ -67,7 +67,15 @@ const COLUMN_LABELS: Record = { coordinationComments: "Coordination Comments", dampAndMouldGrowth: "Damp and Mould Growth", dampMouldAndRepairComments: "Damp, Mould and Repair Comments", - domnaSurveyRequested: "Domna Survey Requested" + domnaSurveyRequested: "Domna Survey Requested", + planningAuthority: "Planning Authority", + designatedArea: "Designated Area", + article4PdRights: "Article 4 PD Rights", + listedBuilding: "Listed Building", + designConstraints: "Design Constraints", + planningComments: "Planning Comments", + planningStatus: "Planning Status", + planningSuggestedApproach: "Planning Suggested Approach", }; type DocFilter = "all" | "has_docs" | "incomplete" | "none"; @@ -114,6 +122,14 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh dampAndMouldGrowth: false, dampMouldAndRepairComments: false, domnaSurveyRequested: false, + planningAuthority: false, + designatedArea: false, + article4PdRights: false, + listedBuilding: false, + designConstraints: false, + planningComments: false, + planningStatus: false, + planningSuggestedApproach: false, }); // Pre-filter by stage, doc status, and removal status before TanStack gets it diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx index aa0157a2..ee43edd5 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx @@ -439,6 +439,102 @@ export function createPropertyTableColumns( }, }, + // ── Planning authority ─────────────────────────────────────────────── + { + accessorKey: "planningAuthority", + id: "planningAuthority", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.planningAuthority ?? } + + ), + }, + + // ── Designated area ────────────────────────────────────────────────── + { + accessorKey: "designatedArea", + id: "designatedArea", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.designatedArea ?? } + + ), + }, + + // ── Article 4 PD rights ────────────────────────────────────────────── + { + accessorKey: "article4PdRights", + id: "article4PdRights", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.article4PdRights ?? } + + ), + }, + + // ── Listed building ────────────────────────────────────────────────── + { + accessorKey: "listedBuilding", + id: "listedBuilding", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.listedBuilding ?? } + + ), + }, + + // ── Design constraints ─────────────────────────────────────────────── + { + accessorKey: "designConstraints", + id: "designConstraints", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.designConstraints ?? } + + ), + }, + + // ── Planning comments ──────────────────────────────────────────────── + { + accessorKey: "planningComments", + id: "planningComments", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.planningComments ?? } + + ), + }, + + // ── Planning status ────────────────────────────────────────────────── + { + accessorKey: "planningStatus", + id: "planningStatus", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.planningStatus ?? } + + ), + }, + + // ── Planning suggested approach ────────────────────────────────────── + { + accessorKey: "planningSuggestedApproach", + id: "planningSuggestedApproach", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.planningSuggestedApproach ?? } + + ), + }, + ]; if (showDocuments) { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts index 68ce00ac..01cc259d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts @@ -73,6 +73,14 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal { domnaSurveyDate: d.domnaSurveyDate, batch: d.batch, batchDescription: d.batchDescription, + planningAuthority: d.planningAuthority, + designatedArea: d.designatedArea, + article4PdRights: d.article4PdRights, + listedBuilding: d.listedBuilding, + designConstraints: d.designConstraints, + planningComments: d.planningComments, + planningStatus: d.planningStatus, + planningSuggestedApproach: d.planningSuggestedApproach, createdAt: d.createdAt, updatedAt: d.updatedAt, }; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts index 32d1671e..cd2c1d08 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts @@ -60,6 +60,14 @@ function makeDeal(overrides: Partial = {}): HubspotDeal { domnaSurveyDate: null, batch: null, batchDescription: null, + planningAuthority: null, + designatedArea: null, + article4PdRights: null, + listedBuilding: null, + designConstraints: null, + planningComments: null, + planningStatus: null, + planningSuggestedApproach: null, coordinationComments: null, domnasurveyRequired: null, createdAt: new Date(), diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts index 6a30d703..3f7fec46 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts @@ -59,6 +59,14 @@ function makeDeal(overrides: Partial = {}): ClassifiedDeal { domnaSurveyDate: null, batch: null, batchDescription: null, + planningAuthority: null, + designatedArea: null, + article4PdRights: null, + listedBuilding: null, + designConstraints: null, + planningComments: null, + planningStatus: null, + planningSuggestedApproach: null, coordinationComments: null, domnasurveyRequired: null, createdAt: new Date(), diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.test.ts index 91501665..b0ee7179 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.test.ts @@ -59,6 +59,14 @@ function makeDeal(overrides: Partial = {}): HubspotDeal { domnaSurveyDate: null, batch: null, batchDescription: null, + planningAuthority: null, + designatedArea: null, + article4PdRights: null, + listedBuilding: null, + designConstraints: null, + planningComments: null, + planningStatus: null, + planningSuggestedApproach: null, coordinationComments: null, domnasurveyRequired: null, createdAt: new Date(), diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.ts index 1b29caab..21296c0c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.ts @@ -30,6 +30,14 @@ export const PROPERTY_CSV_FIELDS: PropertyCsvField[] = [ { key: "dampMouldFlag", label: "Damp and Mould Growth" }, { key: "dampMouldAndRepairComments", label: "Damp Mould and Repair Comments" }, { key: "domnasurveyRequired", label: "Domna Survey Required" }, + { key: "planningAuthority", label: "Planning Authority" }, + { key: "designatedArea", label: "Designated Area" }, + { key: "article4PdRights", label: "Article 4 PD Rights" }, + { key: "listedBuilding", label: "Listed Building" }, + { key: "designConstraints", label: "Design Constraints" }, + { key: "planningComments", label: "Planning Comments" }, + { key: "planningStatus", label: "Planning Status" }, + { key: "planningSuggestedApproach", label: "Planning Suggested Approach" }, ]; export function escapeCsvCell(value: unknown): string { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts index ad7bf045..eae27870 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts @@ -66,6 +66,14 @@ function makeDeal(overrides: Partial = {}): HubspotDeal { domnaSurveyDate: null, batch: null, batchDescription: null, + planningAuthority: null, + designatedArea: null, + article4PdRights: null, + listedBuilding: null, + designConstraints: null, + planningComments: null, + planningStatus: null, + planningSuggestedApproach: null, coordinationComments: null, domnasurveyRequired: null, createdAt: new Date(), diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index aa49fe25..b1501487 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -71,6 +71,16 @@ export type HubspotDeal = { batch: string | null; batchDescription: string | null; + // ── Planning fields (issue: extra PM columns) ───────────────────────── + planningAuthority: string | null; + designatedArea: string | null; + article4PdRights: string | null; + listedBuilding: string | null; + designConstraints: string | null; + planningComments: string | null; + planningStatus: string | null; + planningSuggestedApproach: string | null; + createdAt: Date; updatedAt: Date; }; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx index 92c12eed..ed2080be 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx @@ -6,8 +6,10 @@ import { SparklesIcon, BuildingOfficeIcon, ShieldCheckIcon, + ExclamationTriangleIcon, } from "@heroicons/react/24/outline"; import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid"; +import Link from "next/link"; import { getPropertyMeta, getConditionReport, @@ -67,6 +69,43 @@ export default async function BuildingPassportHome(props: { }) { const params = await props.params; const propertyMeta = await getPropertyMeta(params.propertyId); + + // A property with no UPRN isn't matched to an Ordnance Survey address yet, so + // the whole UPRN-keyed passport (spatial data, installed measures, EPC) has + // nothing to show — and getSpatialData(BigInt(null)) would 500. Surface a + // clear "not matched" state and link back to the portfolio instead. + if (!propertyMeta.uprn) { + return ( +
    +
    + +

    + Not matched to a UPRN yet +

    +

    + {propertyMeta.address ? ( + <> + {propertyMeta.address}{" "} + hasn't{" "} + + ) : ( + "This property hasn't " + )} + been matched to an Ordnance Survey address, so its building passport + isn't available yet. Match it to a UPRN from the portfolio to + unlock the passport. +

    + + Back to portfolio + +
    +
    + ); + } + const conditionReport = await getConditionReport(params.propertyId); const spatial = await getSpatialData(propertyMeta.uprn); const installedMeasures = await getInstalledMeasuresByUprn(Number(propertyMeta.uprn)); diff --git a/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx b/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx new file mode 100644 index 00000000..21e4ffed --- /dev/null +++ b/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import * as XLSX from "xlsx"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { parseTagIdentifierRows, IdentifierKey } from "@/lib/tags/bulkAssign"; +import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags"; +import { TagChip } from "./TagChip"; + +interface Parsed { + key: IdentifierKey; + identifiers: string[]; +} + +interface AssignSummary { + matchedPropertyCount: number; + taggedCount: number; + alreadyTaggedCount: number; + unmatchedCount: number; + unmatchedIdentifiers: string[]; +} + +const KEY_LABEL: Record = { + landlord_property_id: "Property Ref (landlord id)", + uprn: "UPRN", +}; + +/** Download a one-column CSV template the user can fill in and re-upload. */ +function downloadTemplate(key: IdentifierKey) { + const example = + key === "uprn" + ? ["100012345678", "100012345679"] + : ["PROP-0001", "PROP-0002"]; + const csv = [key, ...example].join("\n") + "\n"; + const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8;" })); + const a = document.createElement("a"); + a.href = url; + a.download = `tag-assignment-template-${key}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Bulk tag assignment upload (ADR-0013): reads a small CSV/Excel of identifiers + * client-side, parses it with the shared pure parser, lets the user pick a + * target Tag, then POSTs {mode:"identifiers"} and renders the match summary. + * No FastAPI pipeline — the file matches existing identifiers to Properties. + */ +export function BulkTagUploadModal({ + portfolioId, + open, + onOpenChange, +}: { + portfolioId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { data: tags = [] } = usePortfolioTags(portfolioId); + const invalidate = useInvalidateTagSurfaces(portfolioId); + + const [fileName, setFileName] = useState(null); + const [parsed, setParsed] = useState(null); + const [parseError, setParseError] = useState(null); + const [targetTag, setTargetTag] = useState(null); + const [summary, setSummary] = useState(null); + + function reset() { + setFileName(null); + setParsed(null); + setParseError(null); + setTargetTag(null); + setSummary(null); + assign.reset(); + } + + async function onFile(file: File) { + setParseError(null); + setParsed(null); + setSummary(null); + setFileName(file.name); + try { + const buf = await file.arrayBuffer(); + const wb = XLSX.read(buf, { type: "array" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) as ( + | string + | number + | null + )[][]; + const result = parseTagIdentifierRows(rows); + if (!result.ok) { + setParseError(result.error); + return; + } + setParsed({ key: result.key, identifiers: result.identifiers }); + } catch { + setParseError("Couldn't read that file — is it a valid CSV or Excel?"); + } + } + + const assign = useMutation({ + mutationFn: async () => { + if (!parsed || !targetTag) throw new Error("Pick a file and a tag first"); + const res = await fetch( + `/api/portfolio/${portfolioId}/tags/${targetTag.id}/assignments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: "identifiers", + key: parsed.key, + identifiers: parsed.identifiers, + }), + }, + ); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't assign the tag"); + return body; + }, + onSuccess: (data) => { + setSummary(data); + invalidate(); + }, + }); + + return ( + { + if (!o) reset(); + onOpenChange(o); + }} + > + + + Assign a tag from a file + + Upload a CSV or Excel file with a{" "} + landlord_property_id or{" "} + uprn column. We'll match + those to properties in this portfolio and apply the tag you pick. + + + + {summary ? ( +
    +
    +

    + Tagged {summary.taggedCount.toLocaleString()}{" "} + propert{summary.taggedCount === 1 ? "y" : "ies"} + {targetTag ? ( + <> + {" "} + with{" "} + + + ) : null} + . +

    +
      +
    • {summary.matchedPropertyCount.toLocaleString()} matched the file
    • + {summary.alreadyTaggedCount > 0 && ( +
    • + {summary.alreadyTaggedCount.toLocaleString()} already had the tag +
    • + )} + {summary.unmatchedCount > 0 && ( +
    • + {summary.unmatchedCount.toLocaleString()} identifier + {summary.unmatchedCount === 1 ? "" : "s"} matched no property +
    • + )} +
    +
    + {summary.unmatchedIdentifiers.length > 0 && ( +
    + + Show unmatched identifiers + +

    + {summary.unmatchedIdentifiers.join(", ")} + {summary.unmatchedCount > summary.unmatchedIdentifiers.length && + " …"} +

    +
    + )} +
    + ) : ( +
    + + +

    + Need a starting point? Download a template for{" "} + {" "} + or{" "} + + . +

    + + {parseError && ( +

    {parseError}

    + )} + + {parsed && ( +
    +

    + Found{" "} + + {parsed.identifiers.length.toLocaleString()} + {" "} + identifiers, matching on{" "} + + {KEY_LABEL[parsed.key]} + + . +

    +
    +

    + Apply which tag? +

    + {tags.length === 0 ? ( +

    + No tags yet —{" "} + + create one first + + . +

    + ) : ( +
    + {tags.map((t) => ( + + ))} +
    + )} +
    +
    + )} + + {assign.isError && ( +

    {assign.error.message}

    + )} +
    + )} + + + {summary ? ( + <> + + + + ) : ( + <> + + + + )} + +
    +
    + ); +} diff --git a/src/app/portfolio/[slug]/components/MatchAddress.tsx b/src/app/portfolio/[slug]/components/MatchAddress.tsx new file mode 100644 index 00000000..549420bd --- /dev/null +++ b/src/app/portfolio/[slug]/components/MatchAddress.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + MagnifyingGlassIcon, + CheckCircleIcon, +} from "@heroicons/react/24/outline"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { describeCandidate } from "@/lib/postcodeSearch/model"; +import { + searchAddresses, + useAssignUprn, + type SearchCandidate, +} from "@/lib/properties/client"; +import type { UnmatchedProperty } from "@/lib/properties/unmatched"; + +// Row action for an unmatched property: edit the address, confirm the postcode, +// search Ordnance Survey, and pick from the RESIDENTIAL addresses in that +// postcode. Picking assigns that candidate's UPRN — every candidate is a real +// OS address, so this guarantees a valid, residential UPRN. On success the +// property leaves the "Needs attention" tab (the page re-fetches). +export default function MatchAddress({ + property, + portfolioId, +}: { + property: UnmatchedProperty; + portfolioId: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [address, setAddress] = useState(property.address ?? ""); + const [postcode, setPostcode] = useState(property.postcode ?? ""); + // null = not searched yet; [] = searched, no residential matches. + const [residential, setResidential] = useState(null); + const [selected, setSelected] = useState(null); + const [source, setSource] = useState<"cache" | "live" | null>(null); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + + const assign = useAssignUprn(portfolioId, property.id); + + async function runSearch() { + setSearching(true); + setSearchError(null); + setResidential(null); + setSelected(null); + try { + const result = await searchAddresses(portfolioId, postcode); + // Residential only — we never assign a non-residential UPRN. + setResidential(result.candidates.filter((c) => c.selectable)); + setSource(result.source); + } catch (e) { + setSearchError(e instanceof Error ? e.message : "Search failed."); + } finally { + setSearching(false); + } + } + + function onAssign() { + if (!selected) return; + assign.mutate( + { + uprn: selected.uprn, + address: selected.address, + postcode: selected.postcode, + userInputtedAddress: address.trim() || undefined, + userInputtedPostcode: postcode.trim() || undefined, + }, + { + onSuccess: () => { + setOpen(false); + router.refresh(); + }, + }, + ); + } + + return ( + <> + + + + + + Match to a UPRN + + Confirm the postcode and pick the matching address. Only + residential addresses from Ordnance Survey are shown, so the UPRN + is always valid. + + + +
    +
    + + setAddress(e.target.value)} + placeholder="e.g. 20 Brenchley Road" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
    +
    +
    + + setPostcode(e.target.value)} + placeholder="e.g. BR5 2TD" + onKeyDown={(e) => { + if (e.key === "Enter" && postcode.trim() && !searching) + runSearch(); + }} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
    + +
    + + {searchError &&

    {searchError}

    } + + {residential && ( +
    +
    +

    + Residential addresses +

    + {source === "cache" && ( + + from cache + + )} +
    +
    + {residential.length === 0 ? ( +

    + No residential addresses found for that postcode. +

    + ) : ( +
      + {residential.map((c) => { + const { label } = describeCandidate(c); + const disabled = c.alreadyInPortfolio; + const isSelected = selected?.uprn === c.uprn; + return ( +
    • + +
    • + ); + })} +
    + )} +
    +
    + )} +
    + + + {assign.error && ( +

    + {assign.error.message} +

    + )} + + +
    +
    +
    + + ); +} diff --git a/src/app/portfolio/[slug]/components/PortfolioTabs.tsx b/src/app/portfolio/[slug]/components/PortfolioTabs.tsx new file mode 100644 index 00000000..887fee92 --- /dev/null +++ b/src/app/portfolio/[slug]/components/PortfolioTabs.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; + +// Two-tab shell for the portfolio landing view: the main properties table, and +// a "Needs attention" tab for properties that aren't matched to a UPRN yet. +// Both panels stay mounted (hidden with CSS) so the table keeps its filter / +// pagination state when the user flicks between tabs. +export default function PortfolioTabs({ + needsAttentionCount, + properties, + needsAttention, +}: { + needsAttentionCount: number; + properties: React.ReactNode; + needsAttention: React.ReactNode; +}) { + const [tab, setTab] = useState<"properties" | "needs-attention">("properties"); + + // Nothing unmatched → no tab strip at all, just the properties table. + if (needsAttentionCount === 0) return <>{properties}; + + return ( +
    +
    + setTab("properties")} + > + Properties + + setTab("needs-attention")} + > + + Needs attention + {needsAttentionCount > 0 && ( + + {needsAttentionCount} + + )} + +
    + +
    {properties}
    +
    + {needsAttention} +
    +
    + ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx index 7add0920..f8df4b12 100644 --- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -3,6 +3,8 @@ import React, { useState, useRef, useEffect } from "react"; import { X, Plus, ChevronDown, Check } from "lucide-react"; import { getEpcColorClass } from "@/app/utils"; +import { tagDotColour } from "@/lib/tags/colour"; +import { Tag, usePortfolioTags } from "./useTags"; import { FilterGroups, FilterGroup, @@ -10,13 +12,14 @@ import { FilterField, FilterOperator, DatePreset, - EnumOption, PROPERTY_TYPE_OPTIONS, BUILT_FORM_OPTIONS, - TENURE_OPTIONS, YEAR_BUILT_OPTIONS, MAINFUEL_OPTIONS, PROVENANCE_OPTIONS, + WALL_TYPE_OPTIONS, + ROOF_TYPE_OPTIONS, + HEATING_SYSTEM_OPTIONS, } from "@/app/utils/propertyFilters"; /* ----------------------------------------------------------------------- @@ -32,14 +35,20 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [ { value: "epcExpiryDate", label: "EPC Expiry Date" }, { value: "propertyType", label: "Property Type" }, { value: "builtForm", label: "Built Form" }, - { value: "tenure", label: "Tenure" }, - { value: "yearBuilt", label: "Year Built" }, + { value: "yearBuilt", label: "Construction Age" }, { value: "provenance", label: "Provenance" }, { value: "floorArea", label: "Floor Area (m²)" }, { value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" }, { value: "mainfuel", label: "Main Fuel" }, + { value: "wallType", label: "Wall Type" }, + { value: "roofType", label: "Roof Type" }, + { value: "heatingSystem", label: "Heating System" }, + { value: "tags", label: "Tags" }, ]; +/** The synthetic "no tags" bucket the server SQL recognises (ADR-0013). */ +const UNTAGGED_VALUE = "__untagged__"; + const EPC_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [ { value: "epc_less_than", label: "is worse than" }, { value: "equals", label: "equals" }, @@ -71,13 +80,18 @@ const NUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [ { value: "num_equals", label: "= (equals)" }, ]; -const ENUM_FIELD_OPTIONS: Record = { - propertyType: PROPERTY_TYPE_OPTIONS, - builtForm: BUILT_FORM_OPTIONS, - tenure: TENURE_OPTIONS, - yearBuilt: YEAR_BUILT_OPTIONS, - provenance: PROVENANCE_OPTIONS, - mainfuel: MAINFUEL_OPTIONS, +// Options need only a `label` for the dropdown: the exact-enum fields carry +// EnumOption[] (dbValues used server-side), the free-text descriptor fields carry +// DescriptorBucket[] (needles used server-side). Both satisfy `{ label }`. +const ENUM_FIELD_OPTIONS: Record = { + propertyType: PROPERTY_TYPE_OPTIONS, + builtForm: BUILT_FORM_OPTIONS, + yearBuilt: YEAR_BUILT_OPTIONS, + provenance: PROVENANCE_OPTIONS, + mainfuel: MAINFUEL_OPTIONS, + wallType: WALL_TYPE_OPTIONS, + roofType: ROOF_TYPE_OPTIONS, + heatingSystem: HEATING_SYSTEM_OPTIONS, }; /* ----------------------------------------------------------------------- @@ -95,10 +109,14 @@ function isNumericField(field: FilterField) { return field === "floorArea" || field === "co2Emissions"; } +function isTagField(field: FilterField) { + return field === "tags"; +} + function operatorsForField(field: FilterField): { value: FilterOperator; label: string }[] { if (isEpcField(field)) return EPC_OPERATOR_OPTIONS; if (field === "epcExpiryDate") return DATE_OPERATOR_OPTIONS; - if (isEnumField(field)) return ENUM_OPERATOR_OPTIONS; + if (isEnumField(field) || isTagField(field)) return ENUM_OPERATOR_OPTIONS; if (isNumericField(field)) return NUM_OPERATOR_OPTIONS; return []; } @@ -107,9 +125,22 @@ function defaultOperatorForField(field: FilterField): FilterOperator { return operatorsForField(field)[0]?.value ?? "equals"; } -function conditionLabel(condition: PropertyFilter): string { +function conditionLabel(condition: PropertyFilter, tags: Tag[] = []): string { const fieldLabel = FIELD_OPTIONS.find((f) => f.value === condition.field)?.label ?? condition.field; + if (isTagField(condition.field)) { + try { + const values: string[] = JSON.parse(condition.value); + const byId = new Map(tags.map((t) => [t.id, t.name])); + const names = values.map((v) => + v === UNTAGGED_VALUE ? "Untagged" : (byId.get(v) ?? "(deleted tag)"), + ); + return `${fieldLabel} is one of: ${names.join(", ")}`; + } catch { + return `${fieldLabel} is one of: ${condition.value}`; + } + } + if (isEpcField(condition.field)) { const opLabel = EPC_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator; const value = @@ -217,7 +248,7 @@ function EpcDropdown({ ref={buttonRef} type="button" onClick={openDropdown} - className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition" + className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition" > {selected.length === 0 ? ( @@ -256,7 +287,7 @@ function EpcDropdown({ {multi ? ( {isSelected && } @@ -281,7 +312,7 @@ function EnumMultiDropdown({ selectedLabels, onChange, }: { - options: EnumOption[]; + options: { label: string }[]; selectedLabels: string[]; onChange: (labels: string[]) => void; }) { @@ -341,7 +372,7 @@ function EnumMultiDropdown({ ref={buttonRef} type="button" onClick={openDropdown} - className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition" + className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition" > {selectedLabels.length === 0 ? ( @@ -367,7 +398,134 @@ function EnumMultiDropdown({ {opt.label} + {isSelected && } + + + ); + })} +
    + )} +
    + ); +} + +/* ----------------------------------------------------------------------- + Tag Multi-Select Dropdown (dynamic per-portfolio, id-valued + Untagged) +------------------------------------------------------------------------ */ +function TagMultiDropdown({ + tags, + selected, + onChange, +}: { + tags: Tag[]; + selected: string[]; // tag ids and/or UNTAGGED_VALUE + onChange: (values: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState({}); + const ref = useRef(null); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + useEffect(() => { + function handleOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + function handleScroll(e: Event) { + if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) return; + setOpen(false); + } + if (open) { + document.addEventListener("mousedown", handleOutside); + window.addEventListener("scroll", handleScroll, true); + } + return () => { + document.removeEventListener("mousedown", handleOutside); + window.removeEventListener("scroll", handleScroll, true); + }; + }, [open]); + + function openDropdown() { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: Math.max(rect.width, 220), + zIndex: 9999, + maxHeight: 280, + overflowY: "auto", + }); + } + setOpen((o) => !o); + } + + function toggle(value: string) { + onChange( + selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value], + ); + } + + const byId = new Map(tags.map((t) => [t.id, t])); + const summary = selected + .map((v) => (v === UNTAGGED_VALUE ? "Untagged" : byId.get(v)?.name)) + .filter(Boolean) + .join(", "); + + const options: { value: string; label: string; colour?: string }[] = [ + ...tags.map((t) => ({ value: t.id, label: t.name, colour: t.colour })), + { value: UNTAGGED_VALUE, label: "Untagged" }, + ]; + + return ( +
    + + + {open && ( +
    + {tags.length === 0 && ( +

    + No tags yet — filter by Untagged, or create tags in settings. +

    + )} + {options.map((opt) => { + const isSelected = selected.includes(opt.value); + const untagged = opt.value === UNTAGGED_VALUE; + return ( + @@ -569,15 +742,17 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp ------------------------------------------------------------------------ */ function ConditionRow({ condition, + tags, onRemove, }: { condition: PropertyFilter; + tags: Tag[]; onRemove: () => void; }) { return (
    - {conditionLabel(condition)} + {conditionLabel(condition, tags)}
    @@ -701,6 +880,7 @@ export default function PropertyFilters({ {isAndTarget ? ( setAddMode(null)} /> @@ -723,6 +903,7 @@ export default function PropertyFilters({ {addMode === "or" ? ( setAddMode(null)} /> @@ -758,7 +939,7 @@ export default function PropertyFilters({ diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 21f644b9..92419d1a 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -18,14 +18,14 @@ import { } from "@heroicons/react/24/outline"; import { useRouter } from "next/navigation"; import { HomeIcon } from "@heroicons/react/24/outline"; +import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal"; import { sapToEpc } from "@/app/utils"; -import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns"; -import { PropertyWithRelations } from "@/app/db/schema/property"; import { - TENURE_OPTIONS, - MAINFUEL_OPTIONS, - EnumOption, -} from "@/app/utils/propertyFilters"; + columns, + columnsWithoutSelect, +} from "@/app/portfolio/[slug]/components/propertyTableColumns"; +import { PropertyWithRelations } from "@/app/db/schema/property"; +import { MAINFUEL_OPTIONS, EnumOption } from "@/app/utils/propertyFilters"; import { OPTIONAL_COLUMN_IDS, OPTIONAL_COLUMN_LABELS, @@ -34,8 +34,12 @@ import { VisibilityState, Updater, PaginationState, + RowSelectionState, } from "@tanstack/react-table"; import { Tooltip } from "./Tooltip"; +import { TagsMenu } from "./TagsMenu"; +import { TagActionBar, TagMode } from "./TagActionBar"; +import { BulkTagUploadModal } from "./BulkTagUploadModal"; import { DropdownMenu, @@ -46,20 +50,19 @@ import { DropdownMenuTrigger, } from "@/app/shadcn_components/ui/dropdown-menu"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/app/shadcn_components/ui/dialog"; -import { Button } from "@/app/shadcn_components/ui/button"; /* ---------------------------------------- Export helpers ----------------------------------------- */ -const EXPORT_LIMIT = 1000; +const EXPORT_LIMIT = 6000; + +// Exactly one navy CTA is primary at a time (impeccable distill): Add properties +// leads an empty portfolio, Run modelling leads a populated one; the other demotes +// to the neutral toolbar-button style so the toolbar has a single focal action. +const PRIMARY_CTA = + "flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition"; +const SECONDARY_CTA = + "flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-white text-primary text-xs font-semibold hover:bg-slate-50 transition"; function resolveEnumLabel( options: EnumOption[], @@ -83,11 +86,13 @@ function exportToCsv(data: PropertyWithRelations[]) { "Plan Cost (£)", "Property Type", "Built Form", - "Tenure", - "Year Built", + "Construction Age", "Floor Area (m²)", "CO₂ Emissions (kg/m²/yr)", "Main Fuel", + "Wall Type", + "Roof Type", + "Heating System", ]; const rows = data.map((p) => { @@ -117,11 +122,13 @@ function exportToCsv(data: PropertyWithRelations[]) { p.totalRecommendationCost ? p.totalRecommendationCost.toFixed(2) : "", p.propertyType ?? "", p.builtForm ?? "", - resolveEnumLabel(TENURE_OPTIONS, p.tenure), p.yearBuilt ?? "", p.totalFloorArea != null ? p.totalFloorArea.toFixed(1) : "", p.co2Emissions != null ? p.co2Emissions.toFixed(1) : "", resolveEnumLabel(MAINFUEL_OPTIONS, p.mainfuel), + p.wallType ?? "", + p.roofType ?? "", + p.heatingSystem ?? "", ]; }); @@ -232,43 +239,44 @@ function QuickFilterDropdown({ return (
    - + {isActive && ( + )} - +
    {isOpen && (
    @@ -308,6 +316,7 @@ export default function PropertyTable({ }) { const router = useRouter(); const [sidebarOpen, setSidebarOpen] = useState(false); + const [bulkImportOpen, setBulkImportOpen] = useState(false); const [committedAddress, setCommittedAddress] = useState(""); const [committedPostcode, setCommittedPostcode] = useState(""); @@ -315,12 +324,37 @@ export default function PropertyTable({ const [openFilter, setOpenFilter] = useState(null); const [filterGroups, setFilterGroups] = useState([]); + // Row selection for bulk tagging — keyed by property id (see DataTable getRowId). + const [rowSelection, setRowSelection] = useState({}); + const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]); + // Deliberate "select all matching" escalation (whole set, not just loaded rows). + const [selectAllMatching, setSelectAllMatching] = useState(false); + + const clearSelection = () => { + setRowSelection({}); + setSelectAllMatching(false); + }; + // Any manual checkbox change drops the all-matching escalation back to the + // explicit (possibly now-smaller) row selection. + const handleRowSelectionChange = ( + updater: Updater, + ) => { + setSelectAllMatching(false); + setRowSelection(updater); + }; + + // Bulk-tag flows launched from the Tags menu: a contextual assign/remove mode, + // and the file-upload modal. + const [tagMode, setTagMode] = useState(null); + const [tagUploadOpen, setTagUploadOpen] = useState(false); + // Column visibility — lifted up from DataTable const [columnVisibility, setColumnVisibility] = useState( () => { const init: VisibilityState = {}; OPTIONAL_COLUMN_IDS.forEach((id) => { - init[id] = false; + // Tags ship visible by default (the headline of ADR-0013); the rest hidden. + init[id] = id === "tags"; }); return init; }, @@ -406,8 +440,9 @@ export default function PropertyTable({ filterGroups: [], }); const totalCount = allResponse?.total ?? 0; + const [modelNudgeDismissed, setModelNudgeDismissed] = useState(false); - const DISPLAY_LIMIT = 1000; + const DISPLAY_LIMIT = 250; const LOAD_MORE_SIZE = 100; // ── Extra rows (lazy-loaded pages beyond the initial 1 000) ────────────── @@ -432,6 +467,28 @@ export default function PropertyTable({ [queryData, extraRows], ); + // Properties with no default plan yet — the "not modelled" set. Counted from the + // loaded rows (cheap; avoids a hook per project convention): exact for a + // fully-loaded portfolio, a floor when more rows lazy-load beyond view. + const unmodelledInView = tableData.filter((p) => p.planId == null).length; + + // Bulk-select scope: how many properties match the current view, whether every + // loaded row is ticked, and whether more match than are loaded (so "select all + // matching" is worth offering). matchingCount is filtered-total when a filter + // is active, else the whole portfolio. + const matchingCount = hasActiveFilters ? filteredTotal : totalCount; + const allLoadedSelected = + selectedIds.length > 0 && selectedIds.length === tableData.length; + const moreMatchThanLoaded = tableData.length < matchingCount; + + // The not-modelled banner owns the Run-modelling CTA in this state, so the + // toolbar must NOT also show one — otherwise two identical navy buttons appear. + const showModelNudge = + !isLoading && + totalCount > 0 && + unmodelledInView > 0 && + !modelNudgeDismissed; + // Controlled pagination (lifted from DataTable so we can detect the last page) const [pagination, setPagination] = useState({ pageIndex: 0, @@ -444,6 +501,8 @@ export default function PropertyTable({ prevFilterKeyRef.current = filterKey; if (pagination.pageIndex !== 0) setPagination((p) => ({ ...p, pageIndex: 0 })); + // The selection is filter-scoped — a changed filter invalidates it. + if (selectedIds.length > 0 || selectAllMatching) clearSelection(); } const [isFetchingMore, setIsFetchingMore] = useState(false); @@ -498,15 +557,35 @@ export default function PropertyTable({ if (isOnLastPage && hasMore) loadMore(); }, [isOnLastPage, hasMore, loadMore]); - /* ---------------------------------------- - Delete preview state - ----------------------------------------- */ - const [deletePropertyId, setDeletePropertyId] = useState(null); - const [deletePreview, setDeletePreview] = useState< - { table: string; count: number }[] | null - >(null); - const [previewLoading] = useState(false); - const [previewError] = useState(null); + // Export fetches its own batch (up to EXPORT_LIMIT) for the current filters, + // independent of the on-screen page — the display fetch is deliberately small. + const [isExporting, setIsExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const handleExport = useCallback(async () => { + if (isExporting) return; + setIsExporting(true); + setExportError(null); + try { + const res = await fetch("/api/properties", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + portfolioId, + filters: allFilterGroups, + limit: EXPORT_LIMIT, + offset: 0, + }), + }); + if (!res.ok) throw new Error(); + const json: { data: PropertyWithRelations[] } = await res.json(); + exportToCsv(json.data); + } catch { + // Don't fail silently — a data-trust product must say when an export dies. + setExportError("Export failed — please try again."); + } finally { + setIsExporting(false); + } + }, [isExporting, portfolioId, allFilterGroups]); return (
    @@ -532,6 +611,31 @@ export default function PropertyTable({ properties )} + {(selectedIds.length > 0 || selectAllMatching) && ( + + {selectAllMatching + ? `All ${matchingCount.toLocaleString()} selected` + : `${selectedIds.length.toLocaleString()} selected`} + + + )} + {exportError && ( + + {exportError} + + + )} {hasActiveFilters && ( )} - {/* Add properties */} + {/* Tags — bulk assign / remove / upload (ADR-0013) */} + setTagUploadOpen(true)} + onAssign={() => { + clearSelection(); + setTagMode("assign"); + }} + onRemove={() => { + clearSelection(); + setTagMode("remove"); + }} + /> + + {/* Add properties — primary CTA only while the portfolio is empty */} - + setBulkImportOpen(false)} + portfolioId={portfolioId} + /> + + {/* Run modelling — primary CTA once there are properties to model. + Hidden while the not-modelled banner is up, since the banner already + carries this action (no duplicate navy CTA). */} + {!showModelNudge && ( + + )}
    @@ -736,6 +862,74 @@ export default function PropertyTable({ />
    + {/* Contextual bulk assign/remove strip — shown only while in that mode + (launched from the Tags menu); its target follows the live selection. */} + {tagMode && ( + setSelectAllMatching(true)} + onClearSelection={clearSelection} + onClose={() => { + setTagMode(null); + clearSelection(); + }} + /> + )} + + + + {/* Not-modelled nudge — turns a wall of empty cells into a next step. + Mixed-safe: shown whenever any loaded property lacks a plan, so a + part-modelled portfolio still gets the prompt for the rest. */} + {showModelNudge && ( +
    +
    + +
    +

    + {unmodelledInView === totalCount + ? "None of these properties have been modelled yet" + : `${unmodelledInView.toLocaleString()} ${unmodelledInView === 1 ? "property hasn't" : "properties haven't"} been modelled yet`} +

    +

    + Run modelling to generate Expected EPC ratings and retrofit + costs for them. +

    +
    +
    +
    + + +
    +
    + )} + {/* Display-limit notice */} {isAtDisplayLimit && hasMore && (
    @@ -770,6 +964,7 @@ export default function PropertyTable({ Curate Selection

    @@ -803,8 +998,7 @@ export default function PropertyTable({ ) : ( setDeletePropertyId(id)} + columns={tagMode ? columns : columnsWithoutSelect} columnVisibility={columnVisibility} onColumnVisibilityChange={ setColumnVisibility as ( @@ -815,61 +1009,12 @@ export default function PropertyTable({ onPaginationChange={ setPagination as (updater: Updater) => void } + rowSelection={rowSelection} + onRowSelectionChange={handleRowSelectionChange} /> )}
    - - {/* Delete preview modal */} - { - if (!open) { - setDeletePropertyId(null); - setDeletePreview(null); - } - }} - > - - - Delete property? - - {previewLoading - ? "Calculating what will be deleted…" - : "This action is permanent. Review the impact below."} - - - - {previewError && ( -

    {previewError}

    - )} - - {deletePreview && !previewLoading && ( -
      - {deletePreview.map((row) => ( -
    • - - {row.table.replace(/_/g, " ")} - - {row.count} -
    • - ))} -
    - )} - - - - -
    -
    ); } diff --git a/src/app/portfolio/[slug]/components/TagActionBar.tsx b/src/app/portfolio/[slug]/components/TagActionBar.tsx new file mode 100644 index 00000000..e663417c --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagActionBar.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { CheckIcon, ChevronDownIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/app/shadcn_components/ui/dropdown-menu"; +import { FilterGroups } from "@/app/utils/propertyFilters"; +import { tagDotColour } from "@/lib/tags/colour"; +import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags"; + +export type TagMode = "assign" | "remove"; + +/** + * The contextual strip shown while assigning or removing a tag in bulk + * (ADR-0013). The target is explicit: the ticked rows, or — after ticking every + * loaded row — "all N matching", which the user escalates to deliberately and + * which resolves the whole matching set server-side (not just the loaded page). + * With nothing selected there is no target, so a tag can't be applied by + * accident. Picking a tag adds/removes memberships and reports the count. + */ +export function TagActionBar({ + portfolioId, + mode, + selectedIds, + selectAllMatching, + hasActiveFilters, + filterGroups, + matchingCount, + allLoadedSelected, + moreMatchThanLoaded, + onSelectAllMatching, + onClearSelection, + onClose, +}: { + portfolioId: string; + mode: TagMode; + selectedIds: string[]; + selectAllMatching: boolean; + hasActiveFilters: boolean; + filterGroups: FilterGroups; + matchingCount: number; + allLoadedSelected: boolean; + moreMatchThanLoaded: boolean; + onSelectAllMatching: () => void; + onClearSelection: () => void; + onClose: () => void; +}) { + const { data: tags = [] } = usePortfolioTags(portfolioId); + const invalidate = useInvalidateTagSurfaces(portfolioId); + const [result, setResult] = useState(null); + + const action = mode === "assign" ? "add" : "remove"; + const verb = mode === "assign" ? "Assign" : "Remove"; + const prep = mode === "assign" ? "to" : "from"; + + // The current target — explicit selection only (no implicit "all"). Selecting + // all matching is a deliberate escalation that switches to the filter path. + type Target = { + body: + | { mode: "properties"; propertyIds: string[] } + | { mode: "filter"; filterGroups: FilterGroups }; + label: string; + }; + const target: Target | null = selectAllMatching + ? { + body: { + mode: "filter", + filterGroups: hasActiveFilters ? filterGroups : [], + }, + label: hasActiveFilters + ? `all ${matchingCount.toLocaleString()} matching your filter` + : `all ${matchingCount.toLocaleString()} properties`, + } + : selectedIds.length > 0 + ? { + body: { mode: "properties", propertyIds: selectedIds }, + label: `${selectedIds.length.toLocaleString()} selected propert${selectedIds.length === 1 ? "y" : "ies"}`, + } + : null; + + const apply = useMutation<{ changed: number }, Error, Tag>({ + mutationFn: async (tag) => { + if (!target) throw new Error("Select some properties first"); + const res = await fetch( + `/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...target.body, action }), + }, + ); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't update tags"); + return body; + }, + onSuccess: (data, tag) => { + setResult( + `${mode === "assign" ? "Added" : "Removed"} “${tag.name}” ${prep} ${data.changed.toLocaleString()} propert${data.changed === 1 ? "y" : "ies"}.`, + ); + invalidate(); + }, + onError: (e) => setResult(e.message), + }); + + const showEscalate = + !selectAllMatching && allLoadedSelected && moreMatchThanLoaded; + + return ( +
    + {verb} a tag + + + {target ? ( + <> + {prep}{" "} + {target.label} + + ) : ( + + Tick properties to {mode === "assign" ? "tag" : "untag"} them + + )} + + + {/* Deliberate escalation to the whole matching set (Gmail pattern) — + a clear secondary button (white fill + brand border) so it stands out + on the tinted bar without competing with the navy "Choose a tag". */} + {showEscalate && ( + + )} + {selectAllMatching && ( + + )} + + + + + + + + {verb} which tag? + + + {tags.length === 0 ? ( +
    + No tags yet.{" "} + + Create one + + . +
    + ) : ( + tags.map((t) => ( + apply.mutate(t)} + > + + {t.name} + + )) + )} +
    +
    + + {result && ( + + {result} + + )} + + +
    + ); +} diff --git a/src/app/portfolio/[slug]/components/TagChip.tsx b/src/app/portfolio/[slug]/components/TagChip.tsx new file mode 100644 index 00000000..f2bb8f9c --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagChip.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { X } from "lucide-react"; +import { tagChipTokens } from "@/lib/tags/colour"; + +/** + * A Tag chip in the app's one calm, AA-safe chip vocabulary (ADR-0013, + * PRODUCT.md): a soft tint of the Tag's freeform hex, a colour dot, and dark + * hue-preserving ink that stays legible for any colour. Shared across the + * property table, tags filter, bulk bar, upload, settings, and run UI so every + * surface reads as one system. + */ +export function TagChip({ + name, + colour, + size = "sm", + onRemove, + title, +}: { + name: string; + colour: string; + size?: "xs" | "sm"; + onRemove?: () => void; + title?: string; +}) { + const tokens = tagChipTokens(colour); + const sizing = + size === "xs" + ? "gap-1 px-1.5 py-0.5 text-[10px]" + : "gap-1.5 px-2 py-0.5 text-[11px]"; + const dot = size === "xs" ? "h-1.5 w-1.5" : "h-2 w-2"; + return ( + + + {name} + {onRemove && ( + + )} + + ); +} diff --git a/src/app/portfolio/[slug]/components/TagsCell.tsx b/src/app/portfolio/[slug]/components/TagsCell.tsx new file mode 100644 index 00000000..004a2df1 --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagsCell.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Check, Plus, Tag as TagIcon } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/app/shadcn_components/ui/popover"; +import { PropertyWithRelations, PropertyTag } from "@/app/db/schema/property"; +import { tagDotColour } from "@/lib/tags/colour"; +import { TagChip } from "./TagChip"; +import { + Tag, + usePortfolioTags, + portfolioTagsKey, +} from "./useTags"; +import type { PropertiesResponse } from "./useProperties"; + +/** How many chips to show inline before collapsing the rest into "+N". */ +const MAX_VISIBLE_CHIPS = 2; + +type AssignVars = { tag: Tag; action: "add" | "remove" }; + +/** + * The property table's Tags cell (ADR-0013): shows the property's Tags as chips + * with a "+N" overflow, and an assign popover that toggles membership against + * POST …/assignments {mode:"properties"}. Writes are optimistic across every + * cached property-table query for this portfolio, reconciled on settle. + */ +export function TagsCell({ property }: { property: PropertyWithRelations }) { + const portfolioId = String(property.portfolioId); + const propertyId = String(property.id); + const tags = property.tags ?? []; + + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + const { data: allTags = [], isLoading, isError } = usePortfolioTags(portfolioId); + + const assignedIds = new Set(tags.map((t) => t.id)); + + const assign = useMutation({ + mutationFn: async ({ tag, action }) => { + const res = await fetch( + `/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: "properties", + action, + propertyIds: [propertyId], + }), + }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Couldn't update tags"); + } + return res.json(); + }, + // Optimistically add/remove the chip on every cached property-table page. + onMutate: async ({ tag, action }) => { + await queryClient.cancelQueries({ queryKey: ["properties", portfolioId] }); + const snapshot = queryClient.getQueriesData({ + queryKey: ["properties", portfolioId], + }); + const chip: PropertyTag = { id: tag.id, name: tag.name, colour: tag.colour }; + queryClient.setQueriesData( + { queryKey: ["properties", portfolioId] }, + (prev) => + prev + ? { + ...prev, + data: prev.data.map((row) => + String(row.id) === propertyId + ? { ...row, tags: nextTags(row.tags ?? [], chip, action) } + : row, + ), + } + : prev, + ); + return { snapshot }; + }, + onError: (_e, _vars, context) => { + context?.snapshot.forEach(([key, data]) => + queryClient.setQueryData(key, data), + ); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["properties", portfolioId] }); + queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) }); + }, + }); + + const visible = tags.slice(0, MAX_VISIBLE_CHIPS); + const overflow = tags.length - visible.length; + + return ( + + + + + +
    +

    Assign tags

    +

    + {property.address ?? "This property"} +

    +
    +
    + {isLoading ? ( +

    Loading tags…

    + ) : isError ? ( +

    Couldn't load tags.

    + ) : allTags.length === 0 ? ( +
    + No tags yet.{" "} + + Create one + + . +
    + ) : ( + allTags.map((t) => { + const on = assignedIds.has(t.id); + return ( + + ); + }) + )} +
    +
    + + + Manage tags + + {assign.isError && ( + {assign.error.message} + )} +
    +
    +
    + ); +} + +/** Add or remove a chip from a property's tag list, keeping it name-ordered. */ +function nextTags( + current: PropertyTag[], + chip: PropertyTag, + action: "add" | "remove", +): PropertyTag[] { + if (action === "remove") return current.filter((t) => t.id !== chip.id); + if (current.some((t) => t.id === chip.id)) return current; + return [...current, chip].sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/app/portfolio/[slug]/components/TagsMenu.tsx b/src/app/portfolio/[slug]/components/TagsMenu.tsx new file mode 100644 index 00000000..ad1f02a5 --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagsMenu.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/app/shadcn_components/ui/dropdown-menu"; +import { + ChevronDownIcon, + DocumentArrowUpIcon, + PlusIcon, + MinusIcon, + TagIcon, +} from "@heroicons/react/24/outline"; + +/** + * The single Tags entry point in the property-table toolbar (ADR-0013): a + * dropdown offering the three bulk-tag paths. Upload opens the file modal; + * Assign / Remove enter a contextual mode (TagActionBar) that operates on the + * row selection or the active filter. Consolidates what was an isolated button. + */ +export function TagsMenu({ + onUpload, + onAssign, + onRemove, +}: { + onUpload: () => void; + onAssign: () => void; + onRemove: () => void; +}) { + return ( + + + + + + } + title="Upload tags from file" + desc="Match a spreadsheet of IDs to a tag" + onSelect={onUpload} + /> + } + title="Assign a tag" + desc="Apply a tag to selected rows or your filtered results" + onSelect={onAssign} + /> + } + title="Remove a tag" + desc="Take a tag off selected rows or your filtered results" + onSelect={onRemove} + /> + + + ); +} + +function Item({ + icon, + title, + desc, + onSelect, +}: { + icon: React.ReactNode; + title: string; + desc: string; + onSelect: () => void; +}) { + return ( + + {icon} + + {title} + {desc} + + + ); +} diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx new file mode 100644 index 00000000..024d4225 --- /dev/null +++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx @@ -0,0 +1,92 @@ +import { + ExclamationTriangleIcon, + CheckCircleIcon, +} from "@heroicons/react/24/outline"; +import type { UnmatchedProperty } from "@/lib/properties/unmatched"; +import MatchAddress from "./MatchAddress"; + +// The "Needs attention" tab: properties with no UPRN, separated out of the main +// table until they're matched. Each row lets the user correct the address / +// postcode (UPRN matching is a later step). +export default function UnmatchedProperties({ + properties, + portfolioId, +}: { + properties: UnmatchedProperty[]; + portfolioId: string; +}) { + const count = properties.length; + + if (count === 0) { + return ( +
    + +

    + All properties are matched +

    +

    + Every property has a UPRN — nothing needs attention. +

    +
    + ); + } + + return ( +
    +
    + +
    +
    +

    + Properties without a UPRN +

    + + {count} + +
    +

    + {count === 1 ? "This property" : "These properties"} couldn't be + matched by AraAddressMatcher automatically. Double check the{" "} + {count === 1 ? "address" : "addresses"} and Ara will try again with + AraAddressMatcherPlus. +

    +
    +
    + +
    +
    +
    Address
    +
    Postcode
    +
    Action
    +
    + +
      + {properties.map((p) => ( +
    • +

      + {p.address ?? ( + No address + )} +

      +

      + {p.postcode ?? } +

      +
      + +
      +
    • + ))} +
    +
    +
    + ); +} diff --git a/src/app/portfolio/[slug]/components/dataTable.tsx b/src/app/portfolio/[slug]/components/dataTable.tsx index 18acbb79..a2a9b456 100644 --- a/src/app/portfolio/[slug]/components/dataTable.tsx +++ b/src/app/portfolio/[slug]/components/dataTable.tsx @@ -6,6 +6,7 @@ import { SortingState, PaginationState, VisibilityState, + RowSelectionState, Updater, flexRender, getCoreRowModel, @@ -50,6 +51,9 @@ interface DataTableProps { // Controlled pagination — when omitted the table manages its own pagination state pagination?: PaginationState; onPaginationChange?: (updater: Updater) => void; + // Controlled row selection — when omitted, selection is disabled/uncontrolled + rowSelection?: RowSelectionState; + onRowSelectionChange?: (updater: Updater) => void; } export default function DataTable>({ @@ -60,6 +64,8 @@ export default function DataTable>({ onColumnVisibilityChange, pagination: controlledPagination, onPaginationChange: controlledOnPaginationChange, + rowSelection, + onRowSelectionChange, }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -70,22 +76,35 @@ export default function DataTable>({ const pagination = isControlled ? controlledPagination : internalPagination; const onPaginationChange = isControlled ? controlledOnPaginationChange! : setInternalPagination; + const selectionEnabled = rowSelection !== undefined; + const table = useReactTable({ data, columns, + // Only override row identity when selection is in use: the property table + // needs id-keyed rows so a selection survives pagination + lazy-loaded pages. + // Other consumers (e.g. the plan table) keep TanStack's default index-based + // identity untouched. The idless fallback guards a selection-enabled table + // whose rows happen to lack an id. + getRowId: selectionEnabled + ? (row, index) => (row.id != null ? String(row.id) : String(index)) + : undefined, + getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), autoResetPageIndex: false, + enableRowSelection: selectionEnabled, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, onPaginationChange, onColumnVisibilityChange, + onRowSelectionChange, globalFilterFn: fuzzyFilter, @@ -95,6 +114,7 @@ export default function DataTable>({ globalFilter, pagination, columnVisibility, + rowSelection: rowSelection ?? {}, }, meta: { diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 9d2955d5..9a0111de 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -17,12 +17,10 @@ import { cn } from "@/lib/utils"; import { PropertyWithRelations } from "@/app/db/schema/property"; import { expectedEpcRating } from "./expectedEpc"; import { X } from "lucide-react"; -import { - EnumOption, - TENURE_OPTIONS, - MAINFUEL_OPTIONS, -} from "@/app/utils/propertyFilters"; +import { EnumOption, MAINFUEL_OPTIONS } from "@/app/utils/propertyFilters"; import { CurrentEpcTooltip } from "./CurrentEpcTooltip"; +import { TagsCell } from "./TagsCell"; +import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; /* ----------------------------------------------------------------------- Helpers @@ -36,13 +34,6 @@ function resolveEnumLabel( return opt?.label ?? dbValue; } -function tenureBadgeClass(label: string): string { - if (label.toLowerCase().includes("owner")) return "bg-blue-50 text-blue-700"; - if (label.toLowerCase().includes("private")) return "bg-violet-50 text-violet-700"; - if (label.toLowerCase().includes("social")) return "bg-emerald-50 text-emerald-700"; - return "bg-slate-100 text-slate-500"; -} - function Pill({ children, className = "bg-slate-100 text-slate-600", @@ -62,8 +53,29 @@ function Pill({ /* ----------------------------------------------------------------------- EPC letter bubble ------------------------------------------------------------------------ */ +/** + * The one treatment for an absent value across the table (ADR-0013 impeccable + * pass): a single quiet, AA-legible em-dash with an accessible label — replacing + * the four different empty renderings (blank div, slate-300 "—", italic + * "No cost", "Unknown") the critique flagged. + */ +const EmptyCell = ({ label = "No data" }: { label?: string }) => ( + + — + +); + const EpcLetterBubble = ({ letter }: { letter: string }) => { - if (!letter) return
    ; + if (!letter) + return ( +
    + – +
    + ); return (
    ({ Column metadata ------------------------------------------------------------------------ */ export const OPTIONAL_COLUMN_IDS = [ + "tags", "propertyType", "builtForm", - "tenure", "yearBuilt", "totalFloorArea", "co2Emissions", "mainfuel", + "wallType", + "roofType", + "heatingSystem", ] as const; export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number]; const OPTIONAL_COLUMN_LABELS: Record = { + tags: "Tags", propertyType: "Property Type", builtForm: "Built Form", - tenure: "Tenure", - yearBuilt: "Year Built", + yearBuilt: "Construction Age", totalFloorArea: "Floor Area (m²)", co2Emissions: "CO₂ Emissions", mainfuel: "Main Fuel", + wallType: "Wall Type", + roofType: "Roof Type", + heatingSystem: "Heating System", }; export { OPTIONAL_COLUMN_LABELS }; @@ -193,6 +211,34 @@ export { OPTIONAL_COLUMN_LABELS }; Core columns ------------------------------------------------------------------------ */ const coreColumns: ColumnDef[] = [ + { + id: "select", + enableSorting: false, + enableHiding: false, + // Selects ALL loaded rows (not just the paginated page), so on a small page + // size it still covers everything in view. When more properties match than + // are loaded, the TagActionBar offers "Select all N matching" on top of this. + header: ({ table }) => ( + table.toggleAllRowsSelected(!!v)} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!v)} + /> + ), + }, { accessorKey: "address", enableGlobalFilter: true, @@ -231,10 +277,18 @@ const coreColumns: ColumnDef[] = [ header: () =>
    Property Ref
    , cell: ({ row }) => (
    - {row.original.landlordPropertyId ?? "—"} + {row.original.landlordPropertyId ?? }
    ), }, + { + id: "tags", + // Toggle-able (in OPTIONAL_COLUMN_IDS) but default-visible — see PropertyTable. + // Not sortable/filterable here; the tags filter lives in PropertyFilters. + enableSorting: false, + header: () =>
    Tags
    , + cell: ({ row }) => , + }, { accessorKey: "currentEpc", header: () => ( @@ -303,7 +357,11 @@ const coreColumns: ColumnDef[] = [ const expired = row.original.epcIsExpired; if (!dateStr) { - return
    ; + return ( +
    + +
    + ); } const lodgementDate = new Date(dateStr); @@ -345,13 +403,13 @@ const coreColumns: ColumnDef[] = [ } return ( -
    +
    {cost ? ( £{formatNumber(cost)} ) : ( - No cost + )}
    ); @@ -409,7 +467,7 @@ const optionalColumns: ColumnDef[] = [ header: () =>
    Property Type
    , cell: ({ row }) => { const val = row.original.propertyType; - return val ? {val} : ; + return val ? {val} : ; }, }, { @@ -418,25 +476,17 @@ const optionalColumns: ColumnDef[] = [ header: () =>
    Built Form
    , cell: ({ row }) => { const val = row.original.builtForm; - return val ? {val} : ; - }, - }, - { - id: "tenure", - accessorKey: "tenure", - header: () =>
    Tenure
    , - cell: ({ row }) => { - const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure); - if (!label) return ; - return {label}; + return val ? {val} : ; }, }, { id: "yearBuilt", accessorKey: "yearBuilt", - header: () =>
    Year Built
    , + header: () =>
    Construction Age
    , cell: ({ row }) => ( -
    {row.original.yearBuilt ?? "—"}
    +
    + {row.original.yearBuilt ?? } +
    ), }, { @@ -447,7 +497,7 @@ const optionalColumns: ColumnDef[] = [ const val = row.original.totalFloorArea; return (
    - {val != null ? `${val.toFixed(1)} m²` : "—"} + {val != null ? `${val.toFixed(1)} m²` : }
    ); }, @@ -460,7 +510,7 @@ const optionalColumns: ColumnDef[] = [ const val = row.original.co2Emissions; return (
    - {val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"} + {val != null ? `${val.toFixed(1)} kg/m²/yr` : }
    ); }, @@ -471,12 +521,52 @@ const optionalColumns: ColumnDef[] = [ header: () =>
    Main Fuel
    , cell: ({ row }) => { const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel); - return label ? {label} : ; + return label ? {label} : ; }, }, + { + id: "wallType", + accessorKey: "wallType", + header: () =>
    Wall Type
    , + cell: ({ row }) => , + }, + { + id: "roofType", + accessorKey: "roofType", + header: () =>
    Roof Type
    , + cell: ({ row }) => , + }, + { + id: "heatingSystem", + accessorKey: "heatingSystem", + header: () =>
    Heating System
    , + cell: ({ row }) => , + }, ]; +/** Long free-text descriptor (wall/roof/heating): truncate with a full-value tooltip; muted when Unknown. */ +function DescriptorCell({ value }: { value: string | null }) { + const val = value ?? "Unknown"; + return ( +
    + {val} +
    + ); +} + export const columns: ColumnDef[] = [ ...coreColumns, ...optionalColumns, ]; + +// The same columns without the leading selection checkbox — row-select is only +// shown while bulk-tagging (ADR-0013 impeccable pass), so the default table has +// no persistent checkbox column. Precomputed (stable identity, no useMemo). +export const columnsWithoutSelect: ColumnDef[] = + columns.filter((c) => c.id !== "select"); diff --git a/src/app/portfolio/[slug]/components/useTags.ts b/src/app/portfolio/[slug]/components/useTags.ts new file mode 100644 index 00000000..582f48c9 --- /dev/null +++ b/src/app/portfolio/[slug]/components/useTags.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +/** A portfolio Tag as returned by GET /api/portfolio/[id]/tags (ADR-0013). */ +export interface Tag { + id: string; + name: string; + colour: string; + propertyCount: number; +} + +/** Shared query key so every tag surface (settings, table, filter) stays in sync. */ +export function portfolioTagsKey(portfolioId: string) { + return ["portfolio-tags", portfolioId] as const; +} + +export async function fetchTags(portfolioId: string): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/tags`); + if (!res.ok) throw new Error("Failed to load tags"); + return res.json(); +} + +/** + * The portfolio's Tags, deduped across every component that reads them (chips, + * assign popover, filter, run UI) via a shared query key. TanStack v4 — reads + * with `isLoading`. + */ +export function usePortfolioTags(portfolioId: string) { + return useQuery({ + queryKey: portfolioTagsKey(portfolioId), + queryFn: () => fetchTags(portfolioId), + refetchOnWindowFocus: false, + staleTime: 60_000, + }); +} + +/** Invalidate the tags list + the property table so freshly-assigned chips show. */ +export function useInvalidateTagSurfaces(portfolioId: string) { + const queryClient = useQueryClient(); + return () => { + queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) }); + queryClient.invalidateQueries({ queryKey: ["properties", portfolioId] }); + }; +} diff --git a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx index 3d4808d4..fa074bc8 100644 --- a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx +++ b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx @@ -15,6 +15,8 @@ import { MAX_FILTER_POSTCODES, selectionSummary, } from "@/lib/modellingRuns/model"; +import { usePortfolioTags } from "@/app/portfolio/[slug]/components/useTags"; +import { tagDotColour } from "@/lib/tags/colour"; export interface ScenarioOption { id: string; @@ -77,11 +79,13 @@ function FilterColumn({ options, selected, onToggle, + emptyHint, }: { title: string; - options: { value: string; count?: number }[]; + options: { value: string; label?: string; count?: number; colour?: string }[]; selected: Set; onToggle: (value: string) => void; + emptyHint?: React.ReactNode; }) { return (
    @@ -94,28 +98,38 @@ function FilterColumn({
    - {options.map((o) => { - const on = selected.has(o.value); - return ( - - ); - })} + {options.length === 0 && emptyHint ? ( +

    {emptyHint}

    + ) : ( + options.map((o) => { + const on = selected.has(o.value); + return ( + + ); + }) + )}
    ); @@ -135,6 +149,7 @@ export default function RunModellingClient({ const [selPcs, setSelPcs] = useState>(new Set()); const [selTypes, setSelTypes] = useState>(new Set()); const [selForms, setSelForms] = useState>(new Set()); + const [selTags, setSelTags] = useState>(new Set()); const [confirmOpen, setConfirmOpen] = useState(false); const [done, setDone] = useState<{ properties: number; scenarios: number } | null>(null); @@ -142,9 +157,12 @@ export default function RunModellingClient({ ...(selPcs.size ? { postcodes: [...selPcs].sort() } : {}), ...(selTypes.size ? { propertyTypes: [...selTypes] } : {}), ...(selForms.size ? { builtForms: [...selForms] } : {}), + ...(selTags.size ? { tagIds: [...selTags].map(Number) } : {}), }; const scenarioIds = [...selScenarios].sort(); + const { data: tags = [] } = usePortfolioTags(portfolioId); + const postcodesQuery = useQuery<{ postcode: string; count: number }[], Error>({ queryKey: ["portfolio-postcodes", portfolioId], staleTime: 5 * 60 * 1000, @@ -219,15 +237,21 @@ export default function RunModellingClient({ const remodel = (preview.data?.perScenario ?? []).filter((p) => p.alreadyModelled > 0); const scenarioById = new Map(scenarios.map((s) => [s.id, s])); const startRun = () => { + if (tagsBlockRun) return; if (matched != null && isLargeRun(matched)) setConfirmOpen(true); else run.mutate(); }; - const filterCount = selPcs.size + selTypes.size + selForms.size; + const filterCount = selPcs.size + selTypes.size + selForms.size + selTags.size; const clearFilters = () => { setSelPcs(new Set()); setSelTypes(new Set()); setSelForms(new Set()); + setSelTags(new Set()); }; + // The distributor can't resolve tag_ids yet (backend ask pending, ADR-0013): + // preview counts tags in-app, but an actual run would model the unscoped set. + // Guard the run until the backend lands — remove this once it does. + const tagsBlockRun = selTags.size > 0; return (
    @@ -376,7 +400,7 @@ export default function RunModellingClient({ takes priority over its EPC; “Unknown” picks out homes where we have neither.

    -
    +
    {postcodesQuery.isError ? (
    @@ -422,10 +446,26 @@ export default function RunModellingClient({ selected={selForms} onToggle={(v) => toggle(selForms, setSelForms, v)} /> + ({ + value: t.id, + label: t.name, + colour: t.colour, + count: t.propertyCount, + }))} + selected={selTags} + onToggle={(v) => toggle(selTags, setSelTags, v)} + emptyHint={ + <> + No tags yet — create them from the property table to run a + scenario over a hand-picked group. + + } + />

    - Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run. Tags are coming — - you'll be able to save a selection and reuse it. + Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run.

    @@ -560,17 +600,34 @@ export default function RunModellingClient({
    + {tagsBlockRun && ( +
    + + You can preview a tag selection now, but running scenarios over + tags needs a modelling-engine update that's on the way. The + count above is exact — the run button unlocks once it lands. + +
    + )} {(remodel.length > 0 || run.isError) && (
    {remodel.map((p) => ( diff --git a/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx b/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx index 81707862..4276e4a0 100644 --- a/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx +++ b/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx @@ -44,6 +44,7 @@ export default function AddPropertiesClient({ const [input, setInput] = useState(""); const [inputError, setInputError] = useState(null); const [search, setSearch] = useState<{ pc: string; refresh: number } | null>(null); + const [filter, setFilter] = useState(""); const [basket, setBasket] = useState({}); const [done, setDone] = useState<{ added: number; skipped: number } | null>(null); @@ -110,6 +111,7 @@ export default function AddPropertiesClient({ } setInputError(null); setDone(null); + setFilter(""); setSearch({ pc, refresh: 0 }); }; @@ -130,6 +132,12 @@ export default function AddPropertiesClient({ const addable = data ? data.candidates.filter((c) => c.selectable && !c.alreadyInPortfolio) : []; + const needle = filter.trim().toLowerCase(); + const visible = data + ? needle + ? data.candidates.filter((c) => c.address.toLowerCase().includes(needle)) + : data.candidates + : []; const totalSelected = countSelected(basket); const postcodes = Object.keys(basket); @@ -267,7 +275,9 @@ export default function AddPropertiesClient({

    {data.postcode}

    - {data.candidates.length}{" "} + {needle + ? `${visible.length} of ${data.candidates.length}` + : data.candidates.length}{" "} {data.candidates.length === 1 ? "address" : "addresses"} {addable.length > 0 && ( @@ -307,10 +317,40 @@ export default function AddPropertiesClient({
    + {/* Long postcodes can return ~100 addresses — a filter lets the user + jump to a house number or street name without scrolling. */} + {data.candidates.length > 8 && ( +
    + + setFilter(e.target.value)} + placeholder="Filter these addresses…" + spellCheck={false} + autoComplete="off" + className="w-full border border-slate-200 rounded-xl pl-9 pr-9 py-2 text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10" + /> + {filter && ( + + )} +
    + )} + {/* Long postcodes can return ~100 addresses — scroll inside the card rather than growing the page. */}
    - {data.candidates.map((c) => { + {visible.length === 0 ? ( +

    + No addresses match “{filter.trim()}”. +

    + ) : ( + visible.map((c) => { const { label, note } = describeCandidate(c); const disabled = !c.selectable || c.alreadyInPortfolio; const isSelected = !!selected[c.uprn]; @@ -361,7 +401,8 @@ export default function AddPropertiesClient({ ); - })} + }) + )}
    )} diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 6014cc25..745a0e4f 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -25,12 +25,25 @@ import { totalFloorAreaSql, lodgementDateSql, isExpiredSql, - mainfuelSql, effectiveSapSql, effectiveEpcBandSql, lodgedEpcBandSql, lodgedSapSql, provenanceSignalSql, + propertyTypeOverrideJoin, + resolvedPropertyTypeSql, + builtFormOverrideJoin, + resolvedBuiltFormSql, + constructionAgeOverrideJoin, + resolvedConstructionAgeSql, + mainFuelOverrideJoin, + resolvedMainFuelSql, + wallTypeOverrideJoin, + resolvedWallTypeSql, + roofTypeOverrideJoin, + resolvedRoofTypeSql, + mainHeatingOverrideJoin, + resolvedHeatingSystemSql, } from "@/lib/services/epcSources"; import { FilterGroups, @@ -38,18 +51,19 @@ import { PropertyFilter, PROPERTY_TYPE_OPTIONS, BUILT_FORM_OPTIONS, - TENURE_OPTIONS, YEAR_BUILT_OPTIONS, MAINFUEL_OPTIONS, PROVENANCE_OPTIONS, EnumOption, + DESCRIPTOR_FILTER_CONFIG, + DescriptorBucket, + DescriptorMatch, } from "@/app/utils/propertyFilters"; import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc"; const ENUM_FIELD_DB_OPTIONS: Record = { propertyType: PROPERTY_TYPE_OPTIONS, builtForm: BUILT_FORM_OPTIONS, - tenure: TENURE_OPTIONS, yearBuilt: YEAR_BUILT_OPTIONS, provenance: PROVENANCE_OPTIONS, mainfuel: MAINFUEL_OPTIONS, @@ -508,6 +522,50 @@ function buildEpcSapCondition( return null; } +/** An ILIKE pattern for a needle: prefix ("cavity%") or substring ("%mains gas%"). */ +function descriptorLikePattern(needle: string, match: DescriptorMatch): string { + return match === "prefix" ? `${needle}%` : `%${needle}%`; +} + +/** + * WHERE fragment matching the free-text wall/roof/heating column against the + * selected coarse buckets (mirrors classifyDescriptor). A non-empty bucket + * matches any of its needles; the "Unknown" bucket matches the complement — + * rows matching NO known needle (which includes the resolved 'Unknown' + * terminal). NULL is impossible here (resolvedXSql COALESCEs to 'Unknown'). + */ +function buildDescriptorBucketCondition( + col: ReturnType, + buckets: DescriptorBucket[], + match: DescriptorMatch, + selectedLabels: string[], +): ReturnType | null { + const allNeedles = buckets.flatMap((b) => b.needles); + const parts: ReturnType[] = []; + + for (const label of selectedLabels) { + const bucket = buckets.find((b) => b.label === label); + if (!bucket) continue; + if (bucket.needles.length === 0) { + // "Unknown" = the complement of every known needle. + if (allNeedles.length === 0) return null; + const nots = allNeedles.map( + (n) => sql`${col} NOT ILIKE ${descriptorLikePattern(n, match)}`, + ); + parts.push(sql`(${sql.join(nots, sql` AND `)})`); + } else { + const likes = bucket.needles.map( + (n) => sql`${col} ILIKE ${descriptorLikePattern(n, match)}`, + ); + parts.push(sql`(${sql.join(likes, sql` OR `)})`); + } + } + + if (parts.length === 0) return null; + if (parts.length === 1) return parts[0]; + return sql`(${sql.join(parts, sql` OR `)})`; +} + function buildConditionSql(filter: PropertyFilter): ReturnType | null { switch (filter.field) { case "address": @@ -579,7 +637,6 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul case "propertyType": case "builtForm": - case "tenure": case "yearBuilt": case "provenance": case "mainfuel": { @@ -595,14 +652,15 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul const options = ENUM_FIELD_DB_OPTIONS[filter.field]; const colMap: Record> = { - propertyType: sql`p.property_type`, - builtForm: sql`p.built_form`, - tenure: sql`p.tenure`, - yearBuilt: sql`p.year_built`, + // Override-resolved to match the column + the Run filter (ADR-0012). + propertyType: resolvedPropertyTypeSql, + builtForm: resolvedBuiltFormSql, + yearBuilt: resolvedConstructionAgeSql, provenance: provenanceSignalSql, - // GAP: new-approach properties have no main-fuel string → NULL, so they - // only match the "no value" filter branch. See epcSources.ts. - mainfuel: mainfuelSql(sql`epc`), + // Override-resolved (ADR-0012): the new EPC graph has no main fuel, so a + // new-approach property matches a fuel filter only via a landlord override; + // otherwise it resolves to 'Unknown'. + mainfuel: resolvedMainFuelSql(sql`epc`), }; const col = colMap[filter.field]; @@ -656,6 +714,68 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul if (filter.operator === "num_equals") return sql`${carbon} = ${n}`; return null; } + + case "tags": { + // Value is a JSON array of selected tag ids, optionally with the synthetic + // "__untagged__" bucket. Any-of within tags (ADR-0013). EXISTS on + // property_tag is PK-indexed by property_id — no join to add to the count. + if (filter.operator !== "enum_one_of") return null; + let selected: string[]; + try { + selected = JSON.parse(filter.value); + } catch { + return null; + } + if (selected.length === 0) return null; + + const untagged = selected.includes("__untagged__"); + const ids = selected.filter((s) => /^\d+$/.test(s)); + const parts: ReturnType[] = []; + if (ids.length > 0) { + const placeholders = ids.map((id) => sql`${id}::bigint`); + parts.push( + sql`EXISTS (SELECT 1 FROM property_tag pt WHERE pt.property_id = p.id AND pt.tag_id IN (${sql.join(placeholders, sql`, `)}))`, + ); + } + if (untagged) { + parts.push( + sql`NOT EXISTS (SELECT 1 FROM property_tag pt WHERE pt.property_id = p.id)`, + ); + } + if (parts.length === 0) return null; + if (parts.length === 1) return parts[0]; + return sql`(${sql.join(parts, sql` OR `)})`; + } + + case "wallType": + case "roofType": + case "heatingSystem": { + // Free-text descriptors: bucket the resolved value by prefix/substring + // needles (ADR-0012), mirroring classifyDescriptor. The resolved SQL folds + // override → EPC element description → legacy → 'Unknown'. + if (filter.operator !== "enum_one_of") return null; + + let selectedLabels: string[]; + try { + selectedLabels = JSON.parse(filter.value); + } catch { + return null; + } + if (selectedLabels.length === 0) return null; + + const { buckets, match } = DESCRIPTOR_FILTER_CONFIG[filter.field]; + const colMap: Record> = { + wallType: resolvedWallTypeSql(sql`epc`), + roofType: resolvedRoofTypeSql(sql`epc`), + heatingSystem: resolvedHeatingSystemSql(sql`epc`), + }; + return buildDescriptorBucketCondition( + colMap[filter.field], + buckets, + match, + selectedLabels, + ); + } } return null; } @@ -695,6 +815,15 @@ const EPC_JOIN_FILTER_FIELDS = new Set([ "floorArea", "epcExpiryDate", "mainfuel", + // Override-resolved (ADR-0012): needs the EPC graph joins + the override join. + "propertyType", + "builtForm", + "yearBuilt", + // Free-text descriptors (ADR-0012): resolved SQL reads the EPC element + // description subquery + the override join. + "wallType", + "roofType", + "heatingSystem", ]); const PLAN_JOIN_FILTER_FIELDS = new Set(["expectedEpc"]); @@ -720,7 +849,14 @@ export async function getPropertiesCount( const epcJoins = needsEpcJoins ? sql`LEFT JOIN property_details_epc epc ON epc.property_id = p.id - ${newApproachJoins}` + ${newApproachJoins} + ${propertyTypeOverrideJoin} + ${builtFormOverrideJoin} + ${constructionAgeOverrideJoin} + ${mainFuelOverrideJoin} + ${wallTypeOverrideJoin} + ${roofTypeOverrideJoin} + ${mainHeatingOverrideJoin}` : sql``; const planJoin = needsPlanJoin ? sql`LEFT JOIN LATERAL ( @@ -739,12 +875,58 @@ export async function getPropertiesCount( ${epcJoins} ${planJoin} WHERE p.portfolio_id = ${portfolioId} + -- Unmatched (no-UPRN) properties live in the "Needs attention" tab until + -- resolved, so they're excluded from the main table + its count. + AND p.uprn IS NOT NULL ${combinedWhere} `); return parseInt(result.rows[0]?.count ?? "0", 10); } +/** + * The ids of every Property matching a filter selection — used by filter-driven + * Bulk tag assignment ("tag all N matching the current filter", ADR-0013). Same + * resolution + conditional joins as getPropertiesCount, but returns ids across + * the whole matching set, not a display page. Mirrors the table's `uprn IS NOT + * NULL` exclusion so the count the user saw and the set that gets tagged agree. + */ +export async function getFilteredPropertyIds( + portfolioId: string, + filterGroups: FilterGroups = [], +): Promise { + const combinedWhere = buildWhereClause(filterGroups); + const fields = filterFieldsInUse(filterGroups); + const needsEpcJoins = [...fields].some((f) => EPC_JOIN_FILTER_FIELDS.has(f)); + const needsPlanJoin = [...fields].some((f) => PLAN_JOIN_FILTER_FIELDS.has(f)); + + const epcJoins = needsEpcJoins + ? sql`LEFT JOIN property_details_epc epc ON epc.property_id = p.id + ${newApproachJoins}` + : sql``; + const planJoin = needsPlanJoin + ? sql`LEFT JOIN LATERAL ( + SELECT id, post_sap_points FROM plan + WHERE property_id = p.id + AND portfolio_id = p.portfolio_id + AND is_default = true + ORDER BY created_at DESC + LIMIT 1 + ) pl ON true` + : sql``; + + const result = await db.execute<{ id: string }>(sql` + SELECT DISTINCT p.id AS id + FROM property p + ${epcJoins} + ${planJoin} + WHERE p.portfolio_id = ${portfolioId} + AND p.uprn IS NOT NULL + ${combinedWhere} + `); + return result.rows.map((r) => BigInt(r.id)); +} + export async function getProperties( portfolioId: string, limit: number = 1000, @@ -786,16 +968,39 @@ export async function getProperties( COALESCE(rec.cost, 0) AS "totalRecommendationCost", p.landlord_property_id AS "landlordPropertyId", p.original_sap_points AS "originalSapPoints", - p.property_type AS "propertyType", - p.built_form AS "builtForm", - p.tenure AS tenure, - p.year_built AS "yearBuilt", + -- Property Type is resolved through the landlord-override precedence + -- (override → EPC-derived → 'Unknown'), matching the modelling Run filter. + -- See resolvePropertyType / ADR-0012. + ${resolvedPropertyTypeSql} AS "propertyType", + ${resolvedBuiltFormSql} AS "builtForm", + -- Construction Age is band-native and override-resolved (ADR-0012); the + -- "yearBuilt" key is retained to avoid a wide rename. See resolveConstructionAge. + ${resolvedConstructionAgeSql} AS "yearBuilt", ${lodgementDateSql(sql`epc`)}::text AS "epcLodgementDate", ${isExpiredSql(sql`epc`)} AS "epcIsExpired", ${totalFloorAreaSql(sql`epc`)} AS "totalFloorArea", ${carbonSql(sql`epc`)} AS "co2Emissions", - ${mainfuelSql(sql`epc`)} AS mainfuel, - p.lexiscore AS lexiscore + -- Main Fuel: override → EPC-derived → 'Unknown'. The new EPC graph has no + -- main fuel, so overrides are the only new-approach source (ADR-0012). + ${resolvedMainFuelSql(sql`epc`)} AS mainfuel, + -- Wall / roof / heating: override → EPC element description → legacy → 'Unknown' + -- (ADR-0012). Per-part components (wall/roof) read the main building part. + ${resolvedWallTypeSql(sql`epc`)} AS "wallType", + ${resolvedRoofTypeSql(sql`epc`)} AS "roofType", + ${resolvedHeatingSystemSql(sql`epc`)} AS "heatingSystem", + p.lexiscore AS lexiscore, + -- Portfolio Tags on this property, name-ordered, as JSON (ADR-0013). A + -- correlated subquery keeps the main query at one row per property (a JOIN + -- would multiply rows and break the LATERAL aggregates above). + COALESCE(( + SELECT json_agg( + json_build_object('id', t.id::text, 'name', t.name, 'colour', t.colour) + ORDER BY t.name + ) + FROM property_tag pt + JOIN portfolio_tag t ON t.id = pt.tag_id + WHERE pt.property_id = p.id + ), '[]'::json) AS tags FROM property p -- LATERAL one-row lookups keep this query at "N properties × index probe" -- instead of hash-joining the multi-million-row plan/recommendation tables. @@ -830,7 +1035,17 @@ export async function getProperties( LEFT JOIN property_details_epc epc ON epc.property_id = p.id ${newApproachJoins} + ${propertyTypeOverrideJoin} + ${builtFormOverrideJoin} + ${constructionAgeOverrideJoin} + ${mainFuelOverrideJoin} + ${wallTypeOverrideJoin} + ${roofTypeOverrideJoin} + ${mainHeatingOverrideJoin} WHERE p.portfolio_id = ${portfolioId} + -- Unmatched (no-UPRN) properties live in the "Needs attention" tab until + -- resolved, so they're excluded from the main table. + AND p.uprn IS NOT NULL ${combinedWhere} LIMIT ${limit} OFFSET ${offset}; `); diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index 1f85675e..56b178a6 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -8,12 +8,15 @@ export type FilterField = | "propertyRef" | "propertyType" | "builtForm" - | "tenure" | "yearBuilt" | "provenance" | "floorArea" | "co2Emissions" - | "mainfuel"; + | "mainfuel" + | "wallType" + | "roofType" + | "heatingSystem" + | "tags"; export type FilterOperator = | "contains" @@ -64,13 +67,23 @@ export interface EnumOption { dbValues: string[]; } +// dbValues are the label vocabulary emitted by resolvedPropertyTypeSql (override +// snapshot / EPC-derived label). "Park home" is reachable from an RdSAP code (4) +// or an override; "Unknown" is the never-null terminal + an explicit override — +// it selects properties with no resolvable value at all (ADR-0012). export const PROPERTY_TYPE_OPTIONS: EnumOption[] = [ { label: "House", dbValues: ["House"] }, { label: "Flat", dbValues: ["Flat"] }, { label: "Bungalow", dbValues: ["Bungalow"] }, { label: "Maisonette", dbValues: ["Maisonette"] }, + { label: "Park home", dbValues: ["Park home"] }, + { label: "Unknown", dbValues: ["Unknown"] }, ]; +// dbValues are the label vocabulary emitted by resolvedBuiltFormSql (override +// snapshot / EPC-derived label). "Not Recorded" matches only an explicit +// override of that literal; "Unknown" is the never-null terminal + an explicit +// override — it selects properties with no resolvable value at all (ADR-0012). export const BUILT_FORM_OPTIONS: EnumOption[] = [ { label: "Detached", dbValues: ["Detached"] }, { label: "Semi-Detached", dbValues: ["Semi-Detached"] }, @@ -79,15 +92,7 @@ export const BUILT_FORM_OPTIONS: EnumOption[] = [ { label: "Enclosed End-Terrace", dbValues: ["Enclosed End-Terrace"] }, { label: "Enclosed Mid-Terrace", dbValues: ["Enclosed Mid-Terrace"] }, { label: "Not Recorded", dbValues: ["Not Recorded"] }, -]; - -export const TENURE_OPTIONS: EnumOption[] = [ - { label: "Owner-occupied", dbValues: ["Owner-occupied", "owner-occupied"] }, - { label: "Rented (Private)", dbValues: ["Rented (private)", "rental (private)", "rented (private)"] }, - { label: "Rented (Social)", dbValues: ["Rented (social)", "rental (social)"] }, - { label: "Not Defined", dbValues: ["Not defined - use in the case of a new dwelling for which the intended tenure in not known. It is not to be used for an existing dwelling"] }, - { label: "Unknown", dbValues: ["unknown"] }, - { label: "Not Recorded", dbValues: ["__null__"] }, + { label: "Unknown", dbValues: ["Unknown"] }, ]; // Provenance signal (ADR-0002). dbValues are the raw signal strings emitted by @@ -98,26 +103,210 @@ export const PROVENANCE_OPTIONS: EnumOption[] = [ { label: "Standard", dbValues: ["none"] }, ]; +// Construction Age is band-native (ADR-0012): labels + dbValues are the RdSAP +// band labels emitted by resolvedConstructionAgeSql. These MUST match +// CONSTRUCTION_AGE_BANDS[*].label in epcSources.ts exactly (note the en-dash); +// epcSources.test.ts guards against drift. "Unknown" is the never-null terminal. export const YEAR_BUILT_OPTIONS: EnumOption[] = [ - "1900","1930","1950","1967","1976","1983","1991","1996", - "2003","2007","2008","2009","2010","2011","2012","2013", - "2014","2015","2016","2017","2018","2019","2020","2021", - "2022","2023","2024","2025", -].map((y) => ({ label: y, dbValues: [y] })); + "before 1900", "1900–1929", "1930–1949", "1950–1966", "1967–1975", + "1976–1982", "1983–1990", "1991–1995", "1996–2002", "2003–2006", + "2007–2011", "2012–2022", "2023 onwards", "Unknown", +].map((label) => ({ label, dbValues: [label] })); -export const MAINFUEL_OPTIONS: EnumOption[] = [ - { label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used"] }, - { label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used"] }, - { label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used"] }, - { label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used"] }, - { label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used"] }, - { label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community"] }, - { label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood"] }, +// RdSAP `main_fuel` code → coarse label. The new EPC graph stores the main fuel +// as this numeric code on epc_main_heating_detail.main_fuel_type (see mainfuelSql +// / resolveMainFuel), so every code that can appear must fold into a filter +// option or an overridden/EPC-derived fuel would display but be unfilterable +// (ADR-0012). Codes are the authoritative Model `epc_codes.csv` `main_fuel` table +// (RdSAP), cross-checked against production epc rows: 26→mains gas, 29/39→electric, +// 0/20/25/31/57→"Community scheme", 5/15/33→coal, 6→wood logs, 9→dual fuel. All +// "(community)"/heat-network codes bucket to "Community Heat Network" — being on a +// communal scheme is the salient fact; the scheme's own fuel is a secondary detail. +export const RDSAP_MAIN_FUEL_CODE_LABELS: Record = { + // Mains gas + "1": "Mains Gas", "26": "Mains Gas", + // LPG + "2": "LPG", "3": "LPG", "17": "LPG", "27": "LPG", "38": "LPG", + // Oil + "4": "Oil", "28": "Oil", + // Electricity + "10": "Electricity", "29": "Electricity", "39": "Electricity", + // Coal / mineral solid fuel (anthracite, smokeless, house coal) + "5": "Coal", "14": "Coal", "15": "Coal", "33": "Coal", + // Biomass / wood (logs, pellets, chips) + "6": "Biomass", "7": "Biomass", "8": "Biomass", "12": "Biomass", "16": "Biomass", + // Dual fuel + "9": "Dual Fuel (Mineral + Wood)", + // Biogas + "13": "Biogas", "51": "Biogas", + // Bioethanol + "19": "Bioethanol", "76": "Bioethanol", + // Biodiesel (from biomass / used cooking oil / vegetable / rape seed oil) + "34": "Biodiesel", "35": "Biodiesel", "36": "Biodiesel", "37": "Biodiesel", + "71": "Biodiesel", "72": "Biodiesel", "73": "Biodiesel", "74": "Biodiesel", + // Biodiesel blend + "18": "B30k (Biodiesel blend)", "75": "B30k (Biodiesel blend)", + // Waste combustion + "11": "Waste Combustion", + // Community / heat-network (the dwelling is on a communal scheme) + "0": "Community Heat Network", "20": "Community Heat Network", "21": "Community Heat Network", + "22": "Community Heat Network", "23": "Community Heat Network", "24": "Community Heat Network", + "25": "Community Heat Network", "30": "Community Heat Network", "31": "Community Heat Network", + "32": "Community Heat Network", "41": "Community Heat Network", "42": "Community Heat Network", + "43": "Community Heat Network", "44": "Community Heat Network", "45": "Community Heat Network", + "46": "Community Heat Network", "47": "Community Heat Network", "48": "Community Heat Network", + "49": "Community Heat Network", "50": "Community Heat Network", "52": "Community Heat Network", + "53": "Community Heat Network", "54": "Community Heat Network", "55": "Community Heat Network", + "56": "Community Heat Network", "57": "Community Heat Network", "58": "Community Heat Network", + "99": "Community Heat Network", +}; + +// EPR free-text main_fuel_type strings (Elmhurst/PASHub, ~5% of new-approach +// rows) folded to the same labels. The empty-string sentinel is NULLIF'd to NULL +// upstream and resolves to 'Unknown'. +export const EPR_MAIN_FUEL_STRING_LABELS: Record = { + "Mains gas": "Mains Gas", + "Electricity": "Electricity", + "Bulk LPG": "LPG", + "Heating oil": "Oil", +}; + +// dbValues fold several vocabularies onto one user-facing label: the raw legacy +// EPC mainfuel strings (property_details_epc), the landlord main_fuel override +// values ("mains gas", "LPG (bulk)", … — see MainFuelValues), the new-approach +// RdSAP numeric codes (RDSAP_MAIN_FUEL_CODE_LABELS) and the EPR strings +// (EPR_MAIN_FUEL_STRING_LABELS). resolvedMainFuelSql emits whichever applies; all +// must resolve to the same chip (ADR-0012). descriptorResolution.test asserts +// every override value, RdSAP code and EPR string is covered here. +const MAINFUEL_OPTIONS_BASE: EnumOption[] = [ + { label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used", "mains gas", "mains gas (community)"] }, + { label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used", "LPG (bulk)", "bottled LPG", "LPG special condition"] }, + { label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used", "oil"] }, + { label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used", "electricity", "electricity (community)"] }, + { label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used", "biomass (community)", "wood logs"] }, + { label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community", "house coal", "smokeless coal"] }, + { label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood", "dual fuel (mineral and wood)"] }, { label: "Biogas", dbValues: ["Biogas not community"] }, + { label: "Bioethanol", dbValues: [] }, { label: "Biodiesel", dbValues: ["Heat from boilers using biodiesel from any biomass source community"] }, { label: "B30d (Biodiesel blend)", dbValues: ["B30d community"] }, { label: "B30k (Biodiesel blend)", dbValues: ["B30k not community"] }, + { label: "Waste Combustion", dbValues: [] }, { label: "Community Heat Network", dbValues: ["From heat network data community"] }, { label: "No Heating System", dbValues: ["To be used only when there is no heatinghotwater system", "To be used only when there is no heatinghotwater system or data is from a community network"] }, - { label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!"] }, + { label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!", "Unknown"] }, ]; + +/** Fold the RdSAP code + EPR string vocabularies into each label's dbValues. */ +export const MAINFUEL_OPTIONS: EnumOption[] = MAINFUEL_OPTIONS_BASE.map((opt) => { + const codes = Object.entries(RDSAP_MAIN_FUEL_CODE_LABELS) + .filter(([, label]) => label === opt.label) + .map(([code]) => code); + const strings = Object.entries(EPR_MAIN_FUEL_STRING_LABELS) + .filter(([, label]) => label === opt.label) + .map(([str]) => str); + return { ...opt, dbValues: [...opt.dbValues, ...codes, ...strings] }; +}); + +/* ----------------------------------------------------------------------- + Free-text descriptor buckets: Wall Type / Roof Type / Heating System (ADR-0012) + + Unlike the enum fields above (exact dbValues), the resolved wall/roof/heating + value is a free-text description (override vocabulary, EPC + epc_energy_element.description, or legacy property_details_epc column) with + HIGH cardinality — 88+ distinct EPC wall descriptions, 245 legacy, plus + insulation-thickness variants, "Average thermal transmittance …" U-values and + encoding corruption. Exact matching can't scale, so each coarse bucket is a set + of case-insensitive NEEDLES matched by prefix (wall/roof) or substring + (heating, whose fuel follows a comma). classifyDescriptor is the pure twin of + the SQL bucketing in the portfolio query's buildConditionSql — keep in sync. + Ordering matters: the first bucket whose needle matches wins. +------------------------------------------------------------------------ */ + +export interface DescriptorBucket { + label: string; + /** Lowercase needles, matched per the field's DescriptorMatch mode. Empty = the Unknown terminal (matches the complement). */ + needles: string[]; +} + +export type DescriptorMatch = "prefix" | "contains"; + +// Wall construction. Prefix-matched on the leading construction term. "Stone" +// folds sandstone/limestone + granite/whinstone; "Average thermal transmittance …" +// (a U-value, not a construction type) and corruption fall through to Unknown. +export const WALL_TYPE_OPTIONS: DescriptorBucket[] = [ + { label: "Cavity", needles: ["cavity"] }, + { label: "Solid Brick", needles: ["solid brick"] }, + { label: "Timber Frame", needles: ["timber frame"] }, + { label: "Stone", needles: ["sandstone", "granite"] }, + { label: "System Built", needles: ["system built"] }, + { label: "Park Home", needles: ["park home"] }, + { label: "Cob", needles: ["cob"] }, + { label: "Curtain Wall", needles: ["curtain"] }, + { label: "Basement", needles: ["basement"] }, + { label: "Unknown", needles: [] }, +]; + +// Roof form. Prefix-matched. "Premises Above" folds the "(another dwelling +// above)" / "(other premises above)" family (a flat with a dwelling above it, so +// no own roof). U-values fall through to Unknown. +export const ROOF_TYPE_OPTIONS: DescriptorBucket[] = [ + { label: "Pitched", needles: ["pitched"] }, + { label: "Flat", needles: ["flat"] }, + { label: "Room-in-Roof", needles: ["roof room"] }, + { label: "Thatched", needles: ["thatched"] }, + { label: "Premises Above", needles: ["(another", "(other", "(same", "another dwelling", "another premises", "other premises"] }, + { label: "Unknown", needles: [] }, +]; + +// Heating system. Substring-matched (the fuel follows a comma, e.g. "Boiler and +// radiators, mains gas"). Order is load-bearing: Heat Pump / Community / Solid +// Fuel / Oil / LPG are checked before the gas + electric buckets so a +// heat-pump/community/wood/oil/LPG system isn't miscaught by "gas"/"electric"; +// Gas Room Heater before Gas Boiler; Electric Storage before Direct Electric. +export const HEATING_SYSTEM_OPTIONS: DescriptorBucket[] = [ + { label: "Heat Pump", needles: ["heat pump"] }, + { label: "Community", needles: ["community"] }, + { label: "Solid Fuel", needles: ["coal", "anthracite", "smokeless", "wood", "dual fuel", "biomass", "solid fuel"] }, + // NB: bare "oil" is a substring of "b(oil)er" — match the fuel token only (", oil" + // as in "Boiler and radiators, oil") and the leading override "Oil room heater". + { label: "Oil", needles: [", oil", "oil room heater"] }, + { label: "LPG", needles: ["lpg"] }, + { label: "Gas Room Heater", needles: ["gas room heater", "room heaters, mains gas", "room heaters, gas"] }, + { label: "Gas Boiler", needles: ["mains gas", "gas boiler", "gas cpsu"] }, + { label: "Electric Storage", needles: ["storage heater"] }, + { label: "Direct Electric", needles: ["electric"] }, + { label: "Unknown", needles: [] }, +]; + +/** Filter config per descriptor field: its buckets + how needles are matched. */ +export const DESCRIPTOR_FILTER_CONFIG: Record< + "wallType" | "roofType" | "heatingSystem", + { buckets: DescriptorBucket[]; match: DescriptorMatch } +> = { + wallType: { buckets: WALL_TYPE_OPTIONS, match: "prefix" }, + roofType: { buckets: ROOF_TYPE_OPTIONS, match: "prefix" }, + heatingSystem: { buckets: HEATING_SYSTEM_OPTIONS, match: "contains" }, +}; + +/** + * Classify a resolved free-text descriptor into its coarse filter bucket: the + * first bucket with a matching needle, else "Unknown". Pure twin of the SQL + * bucketing in buildConditionSql (portfolio query) — keep in sync. + */ +export function classifyDescriptor( + value: string | null | undefined, + buckets: DescriptorBucket[], + match: DescriptorMatch, +): string { + if (value == null) return "Unknown"; + const v = value.toLowerCase(); + for (const bucket of buckets) { + for (const needle of bucket.needles) { + if (match === "prefix" ? v.startsWith(needle) : v.includes(needle)) { + return bucket.label; + } + } + } + return "Unknown"; +} diff --git a/src/lib/bulkDocumentDownload/model.test.ts b/src/lib/bulkDocumentDownload/model.test.ts new file mode 100644 index 00000000..c2d9d007 --- /dev/null +++ b/src/lib/bulkDocumentDownload/model.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_BULK_DOWNLOAD_PROPERTIES, + buildBulkDownloadRecord, + buildSelectionConfig, + bulkDownloadRefetchInterval, + parseDelivery, +} from "./model"; + +describe("buildSelectionConfig — UI selection → stored config", () => { + const portfolioId = 814; + + it("one project + include all → project_codes", () => { + const r = buildSelectionConfig({ + scope: { kind: "project", projectCode: "ABRI-2024-01" }, + includeAll: true, + landlordPropertyIds: [], + portfolioId, + }); + expect(r).toEqual({ + ok: true, + config: { project_codes: ["ABRI-2024-01"], portfolio_id: 814 }, + }); + }); + + it("one project + ticked subset → landlord_property_ids (scope ignored)", () => { + const r = buildSelectionConfig({ + scope: { kind: "project", projectCode: "ABRI-2024-01" }, + includeAll: false, + landlordPropertyIds: ["LP1023", "LP1044"], + portfolioId, + }); + expect(r).toEqual({ + ok: true, + config: { landlord_property_ids: ["LP1023", "LP1044"], portfolio_id: 814 }, + }); + }); + + it("all projects + include all → every project code", () => { + const r = buildSelectionConfig({ + scope: { kind: "all-projects", projectCodes: ["A-1", "B-2"] }, + includeAll: true, + landlordPropertyIds: [], + portfolioId, + }); + expect(r).toEqual({ + ok: true, + config: { project_codes: ["A-1", "B-2"], portfolio_id: 814 }, + }); + }); + + it("all projects + ticked subset → landlord_property_ids", () => { + const r = buildSelectionConfig({ + scope: { kind: "all-projects", projectCodes: ["A-1", "B-2"] }, + includeAll: false, + landlordPropertyIds: ["LP1"], + portfolioId, + }); + expect(r).toEqual({ + ok: true, + config: { landlord_property_ids: ["LP1"], portfolio_id: 814 }, + }); + }); + + it("de-duplicates ids and codes", () => { + expect( + buildSelectionConfig({ + scope: { kind: "all-projects", projectCodes: ["A-1", "A-1"] }, + includeAll: true, + landlordPropertyIds: [], + portfolioId, + }), + ).toEqual({ ok: true, config: { project_codes: ["A-1"], portfolio_id: 814 } }); + + expect( + buildSelectionConfig({ + scope: { kind: "project", projectCode: "A-1" }, + includeAll: false, + landlordPropertyIds: ["LP1", "LP1", " LP1 "], + portfolioId, + }), + ).toEqual({ + ok: true, + config: { landlord_property_ids: ["LP1"], portfolio_id: 814 }, + }); + }); + + it("rejects an empty subset", () => { + const r = buildSelectionConfig({ + scope: { kind: "project", projectCode: "A-1" }, + includeAll: false, + landlordPropertyIds: [], + portfolioId, + }); + expect(r.ok).toBe(false); + }); + + it("rejects include-all with no usable codes", () => { + const r = buildSelectionConfig({ + scope: { kind: "all-projects", projectCodes: ["", " "] }, + includeAll: true, + landlordPropertyIds: [], + portfolioId, + }); + expect(r.ok).toBe(false); + }); + + it("rejects a subset over the 500 cap with the detail message", () => { + const ids = Array.from({ length: 501 }, (_, i) => `LP${i}`); + const r = buildSelectionConfig({ + scope: { kind: "project", projectCode: "A-1" }, + includeAll: false, + landlordPropertyIds: ids, + portfolioId, + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain(`over the ${MAX_BULK_DOWNLOAD_PROPERTIES} limit`); + expect(r.error).toContain("501 properties"); + } + }); +}); + +describe("buildBulkDownloadRecord", () => { + it("writes the app-owned task marker and the config as JSON inputs", () => { + const record = buildBulkDownloadRecord({ + portfolioId: "814", + config: { project_codes: ["A-1"], portfolio_id: 814 }, + }); + expect(record.taskSource).toBe("app:bulk_document_download"); + expect(record.source).toBe("portfolio_id"); + expect(record.sourceId).toBe("814"); + expect(record.status).toBe("waiting"); + expect(JSON.parse(record.inputs)).toEqual({ + project_codes: ["A-1"], + portfolio_id: 814, + }); + }); +}); + + +describe("parseDelivery", () => { + it("reads a finished delivery", () => { + const d = parseDelivery( + JSON.stringify({ + presigned_url: "https://x", + package_s3_key: "k", + included: 137, + skipped: [{ landlord_property_id: "LP9", s3_key: "k", reason: "unreadable" }], + }), + ); + expect(d).toEqual({ + presignedUrl: "https://x", + packageS3Key: "k", + included: 137, + skipped: [{ landlord_property_id: "LP9", s3_key: "k", reason: "unreadable" }], + }); + }); + + it("tolerates link-key naming variants", () => { + expect(parseDelivery(JSON.stringify({ presignedUrl: "https://a" }))?.presignedUrl).toBe( + "https://a", + ); + expect(parseDelivery(JSON.stringify({ download_url: "https://b" }))?.presignedUrl).toBe( + "https://b", + ); + expect(parseDelivery(JSON.stringify({ url: "https://c", s3_key: "k" }))).toMatchObject({ + presignedUrl: "https://c", + packageS3Key: "k", + }); + }); + + it("returns null for in-flight or malformed outputs", () => { + expect(parseDelivery(null)).toBeNull(); + expect(parseDelivery("")).toBeNull(); + expect(parseDelivery("{not json")).toBeNull(); + expect(parseDelivery(JSON.stringify({ included: 0 }))).toBeNull(); + }); +}); + +describe("bulkDownloadRefetchInterval", () => { + it("polls while preparing, stops once resolved", () => { + expect(bulkDownloadRefetchInterval(undefined)).toBe(8_000); + expect(bulkDownloadRefetchInterval({ state: "preparing", delivery: null })).toBe(8_000); + expect( + bulkDownloadRefetchInterval({ + state: "ready", + delivery: { presignedUrl: "x", packageS3Key: null, included: 1, skipped: [] }, + }), + ).toBe(false); + expect(bulkDownloadRefetchInterval({ state: "failed", delivery: null })).toBe(false); + }); +}); diff --git a/src/lib/bulkDocumentDownload/model.ts b/src/lib/bulkDocumentDownload/model.ts new file mode 100644 index 00000000..3eee8c60 --- /dev/null +++ b/src/lib/bulkDocumentDownload/model.ts @@ -0,0 +1,194 @@ +/** + * Bulk-document-download domain model — pure logic for the Documents bulk + * download. Mirrors the Modelling-run trigger convention (ADR-0008): one + * app-authored `tasks` row whose `inputs` carries the selection config, then + * the backend is handed only the task id. + * + * The backend resolves the union of (all properties in the given project + * codes) and (the hand-picked landlord property ids), de-duplicated. Rule of + * thumb: "include all" sends `project_codes` (so properties without a + * landlord_property_id are still swept in); a ticked subset sends + * `landlord_property_ids`. "All projects" enumerates the portfolio's project + * codes — there is no server-side "whole portfolio" option by design. + */ + +/** Backend cap; over this the trigger is rejected with a 400. */ +export const MAX_BULK_DOWNLOAD_PROPERTIES = 500; + +/** + * The selection config, exactly as it is stored (JSON) in `tasks.inputs` and + * read back by the worker. snake_case because the backend parses it directly — + * do not add app-provenance keys here (the recipient email is resolved from + * the JWT at dispatch, not carried in the config). + */ +export interface BulkDownloadConfig { + /** HubSpot project codes (hubspot_deal_data.project_code). */ + project_codes?: string[]; + /** The hand-pick key (property.landlord_property_id). */ + landlord_property_ids?: string[]; + /** Names the ZIP only. */ + portfolio_id?: number; +} + +/** Which projects the download is scoped to. */ +export type BulkDownloadScope = + | { kind: "project"; projectCode: string } + | { kind: "all-projects"; projectCodes: string[] }; + +/** The UI's selection intent, resolved into a config server-side. */ +export interface BulkDownloadSelectionInput { + scope: BulkDownloadScope; + /** true → "include all" (send project_codes); false → send the ticked ids. */ + includeAll: boolean; + /** The ticked landlord_property_ids — used only when includeAll is false. */ + landlordPropertyIds: string[]; + portfolioId: number; +} + +export type BuildConfigResult = + | { ok: true; config: BulkDownloadConfig } + | { ok: false; error: string }; + +/** + * Map the UI selection onto the stored config: + * include all → { project_codes, portfolio_id } + * ticked subset → { landlord_property_ids, portfolio_id } + * At least one of project_codes / landlord_property_ids must be non-empty. + */ +export function buildSelectionConfig( + input: BulkDownloadSelectionInput, +): BuildConfigResult { + const portfolio_id = input.portfolioId; + + if (input.includeAll) { + const rawCodes = + input.scope.kind === "project" + ? [input.scope.projectCode] + : input.scope.projectCodes; + const project_codes = [ + ...new Set(rawCodes.map((c) => c.trim()).filter((c) => c.length > 0)), + ]; + if (project_codes.length === 0) { + return { ok: false, error: "Pick at least one project to download." }; + } + return { ok: true, config: { project_codes, portfolio_id } }; + } + + const landlord_property_ids = [ + ...new Set(input.landlordPropertyIds.map((i) => i.trim()).filter(Boolean)), + ]; + if (landlord_property_ids.length === 0) { + return { + ok: false, + error: "Tick at least one property, or choose “include all”.", + }; + } + if (landlord_property_ids.length > MAX_BULK_DOWNLOAD_PROPERTIES) { + return { + ok: false, + error: `The selection has ${landlord_property_ids.length} properties, over the ${MAX_BULK_DOWNLOAD_PROPERTIES} limit — narrow your selection.`, + }; + } + return { ok: true, config: { landlord_property_ids, portfolio_id } }; +} + +/** + * The marker the worker keys off, in `task_source` (ADR-0011). It receives only + * the task id, re-reads this row, and guards on this value before assembling. + */ +export const BULK_DOWNLOAD_TASK_SOURCE = "app:bulk_document_download"; + +/** + * The row a bulk download persists: an app-authored task whose `inputs` carries + * the selection config. Single marker in `task_source` (ADR-0011) — `service` + * is left null; `source`/`source_id` scope it to the Portfolio (and attach it + * to the portfolio's logs). + */ +export function buildBulkDownloadRecord(args: { + portfolioId: string; + config: BulkDownloadConfig; +}): { + taskSource: typeof BULK_DOWNLOAD_TASK_SOURCE; + source: "portfolio_id"; + sourceId: string; + status: "waiting"; + inputs: string; +} { + return { + taskSource: BULK_DOWNLOAD_TASK_SOURCE, + source: "portfolio_id", + sourceId: args.portfolioId, + status: "waiting", + inputs: JSON.stringify(args.config), + }; +} + +// ── Delivery / status (optional in-app polling) ────────────────────────────── + +export interface BulkDownloadSkipped { + landlord_property_id: string; + s3_key: string; + reason: string; +} + +export interface BulkDownloadDelivery { + /** ZIP link, valid 60 minutes. */ + presignedUrl: string; + packageS3Key: string | null; + /** Documents included. */ + included: number; + skipped: BulkDownloadSkipped[]; +} + +export type BulkDownloadState = "preparing" | "ready" | "failed"; + +export interface BulkDownloadStatus { + state: BulkDownloadState; + delivery: BulkDownloadDelivery | null; +} + +/** + * Read the worker's `sub_task.outputs` back into the app's shape. Null for + * anything that isn't a finished delivery (in-flight, malformed) — polling must + * never throw on an unfinished row. + */ +export function parseDelivery(outputs: string | null): BulkDownloadDelivery | null { + if (!outputs) return null; + try { + const raw = JSON.parse(outputs) as Record; + // Tolerate link-key naming variants so a completed job surfaces its link. + const url = [raw.presigned_url, raw.presignedUrl, raw.download_url, raw.url].find( + (v): v is string => typeof v === "string" && v.length > 0, + ); + if (!url) return null; + return { + presignedUrl: url, + packageS3Key: + typeof raw.package_s3_key === "string" + ? raw.package_s3_key + : typeof raw.s3_key === "string" + ? raw.s3_key + : null, + included: typeof raw.included === "number" ? raw.included : 0, + skipped: Array.isArray(raw.skipped) + ? (raw.skipped as BulkDownloadSkipped[]) + : [], + }; + } catch { + return null; + } +} + +/** + * Poll cadence: keep checking while the ZIP is being built, stop once it is + * ready or failed. Shape matches TanStack v4's refetchInterval callback, which + * receives the query data first. + */ +export function bulkDownloadRefetchInterval( + data: BulkDownloadStatus | undefined, +): number | false { + // Package assembly takes minutes, so a gentle cadence — email is the real + // delivery channel; this poll is just an in-session convenience. + if (!data) return 8_000; + return data.state === "preparing" ? 8_000 : false; +} diff --git a/src/lib/bulkDocumentDownload/server.ts b/src/lib/bulkDocumentDownload/server.ts new file mode 100644 index 00000000..88292f70 --- /dev/null +++ b/src/lib/bulkDocumentDownload/server.ts @@ -0,0 +1,171 @@ +import { db } from "@/app/db/db"; +import { tasks } from "@/app/db/schema/tasks/tasks"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; +import { and, desc, eq } from "drizzle-orm"; +import { + BULK_DOWNLOAD_TASK_SOURCE, + BulkDownloadSelectionInput, + BulkDownloadStatus, + buildBulkDownloadRecord, + buildSelectionConfig, + parseDelivery, +} from "./model"; + +// The document-service entry point on the backend. It resolves the selection +// from the task's `inputs`, assembles the ZIP, emails the link (recipient from +// the JWT), and writes the result to the task's sub_task.outputs. +const BULK_DOWNLOAD_ENDPOINT = "/v1/documents/bulk-download"; + +type Dispatch = { ok: true } | { ok: false; status: number; message: string }; + +function isFailed(status: string | null): boolean { + return status ? ["failed", "failure", "error"].includes(status.toLowerCase()) : false; +} + +function defaultMessageForStatus(status: number): string { + if (status === 409) return "A download for this selection has already been started."; + if (status === 400) return "That selection can’t be downloaded — please adjust it and try again."; + return "Couldn’t start the download — please try again."; +} + +/** Pull FastAPI's error detail so a 400 (e.g. over the 500 cap) reaches the user verbatim. */ +async function extractDetail(res: Response): Promise { + try { + const body = (await res.json()) as Record; + const d = body.detail ?? body.message ?? body.error; + if (typeof d === "string" && d.trim()) return d; + if (Array.isArray(d) && typeof (d[0] as { msg?: unknown })?.msg === "string") { + return (d[0] as { msg: string }).msg; + } + } catch { + // not JSON — fall through to the status default + } + return defaultMessageForStatus(res.status); +} + +async function dispatchBulkDownload( + taskId: string, + sessionToken: string | undefined, +): Promise { + const url = process.env.FASTAPI_API_URL; + const key = process.env.FASTAPI_API_KEY; + if (!url || !key) { + console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); + return { ok: false, status: 500, message: "Server misconfiguration" }; + } + try { + const res = await fetch(`${url}${BULK_DOWNLOAD_ENDPOINT}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": key, + // The recipient email is resolved from this JWT; no email in the body. + Authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify({ task_id: taskId }), + }); + if (res.ok) return { ok: true }; + return { ok: false, status: res.status, message: await extractDetail(res) }; + } catch (err) { + console.error(`Failed to reach FastAPI ${BULK_DOWNLOAD_ENDPOINT}:`, err); + return { + ok: false, + status: 502, + message: "Couldn’t reach the document service — please try again.", + }; + } +} + +export type BulkDownloadTriggerOutcome = + | { kind: "ok"; taskId: string } + | { kind: "invalid_selection"; message: string } + | { kind: "backend_error"; status: number; message: string }; + +/** + * Record + dispatch a bulk document download (Modelling-run convention, + * ADR-0008): one app-authored task whose `inputs` carries the selection + * config, then the document service is handed only the task id — so a large + * selection never travels in the HTTP body. A rejected dispatch marks the task + * failed so it never lingers as a ghost. + */ +export async function triggerBulkDocumentDownload(args: { + selection: BulkDownloadSelectionInput; + sessionToken: string | undefined; +}): Promise { + const built = buildSelectionConfig(args.selection); + if (!built.ok) return { kind: "invalid_selection", message: built.error }; + + const record = buildBulkDownloadRecord({ + portfolioId: String(args.selection.portfolioId), + config: built.config, + }); + const now = new Date(); + const [task] = await db + .insert(tasks) + .values({ ...record, jobStarted: now }) + .returning(); + + const dispatch = await dispatchBulkDownload(task.id, args.sessionToken); + if (!dispatch.ok) { + await db.update(tasks).set({ status: "failed" }).where(eq(tasks.id, task.id)); + return { kind: "backend_error", status: dispatch.status, message: dispatch.message }; + } + + await db.update(tasks).set({ status: "in progress" }).where(eq(tasks.id, task.id)); + return { kind: "ok", taskId: task.id }; +} + +function isComplete(status: string | null): boolean { + return status + ? ["completed", "complete", "success", "succeeded", "done"].includes( + status.toLowerCase(), + ) + : false; +} + +/** + * Status of a triggered download for in-app polling. The worker writes the + * presigned URL to the sub_task's `outputs` on success; a selection that yields + * zero documents fails the sub_task (no empty ZIP). Terminal state is taken from + * completion/failure *status* (task or sub_task) as well as the parsed outputs — + * so a finished job never hangs on "preparing" just because the link couldn't be + * parsed. Portfolio-scoped so a task id can't be read against another portfolio. + */ +export async function getBulkDownloadStatus( + portfolioId: string, + taskId: string, +): Promise { + const rows = await db + .select({ + taskStatus: tasks.status, + taskDone: tasks.jobCompleted, + subStatus: subTasks.status, + subDone: subTasks.jobCompleted, + outputs: subTasks.outputs, + }) + .from(tasks) + .leftJoin(subTasks, eq(subTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.id, taskId), + eq(tasks.taskSource, BULK_DOWNLOAD_TASK_SOURCE), + eq(tasks.sourceId, portfolioId), + ), + ) + .orderBy(desc(subTasks.updatedAt)); + + if (rows.length === 0) return null; + + const delivery = rows.map((r) => parseDelivery(r.outputs)).find(Boolean) ?? null; + const failed = + isFailed(rows[0].taskStatus) || rows.some((r) => isFailed(r.subStatus)); + // A job whose sub_task (or task) reports completion is terminal even if the + // outputs couldn't be parsed into a link — the link is still emailed. + const complete = + isComplete(rows[0].taskStatus) || + !!rows[0].taskDone || + rows.some((r) => isComplete(r.subStatus) || !!r.subDone); + + const state = delivery || complete ? "ready" : failed ? "failed" : "preparing"; + return { state, delivery }; +} diff --git a/src/lib/bulkUpload/addressMatches.ts b/src/lib/bulkUpload/addressMatches.ts new file mode 100644 index 00000000..8d3213ab --- /dev/null +++ b/src/lib/bulkUpload/addressMatches.ts @@ -0,0 +1,246 @@ +import { eq } from "drizzle-orm"; +import * as XLSX from "xlsx"; + +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { bulkUploadUprnCorrections } from "@/app/db/schema/bulk_upload_uprn_corrections"; +import { createRetrofitDataS3Client } from "@/app/utils/s3"; +import { SOURCE_ROW_ID_COLUMN } from "./s3Keys"; + +// Columns address2uprn writes onto each combiner row (ADR-0057). `status` is the +// per-row outcome; a withheld/ambiguous/no-match row has an empty `uprn`. +const UPRN_COL = "address2uprn_uprn"; +const STATUS_COL = "address2uprn_status"; +const LEXISCORE_COL = "address2uprn_lexiscore"; + +// A combiner row is confidently matched only when address2uprn kept a UPRN AND +// did not flag it. Everything else — unmatched, ambiguous_duplicate, +// invalid_postcode, error, or a legacy row with no status but no UPRN — needs +// the user to resolve it on the Addresses tab before finalise. +function isConfidentlyMatched(row: Record): boolean { + const uprn = (row[UPRN_COL] ?? "").trim(); + const status = (row[STATUS_COL] ?? "").trim(); + if (!uprn) return false; + return status === "" || status === "matched"; +} + +export interface FlaggedAddressRow { + sourceRowId: string; + address: string; + postcode: string; + status: string; + lexiscore: number | null; + // The candidate address2uprn found but did not trust (null when none). + suggestedUprn: string | null; + // The user's confirmation (from bulk_upload_uprn_corrections), if any. + resolvedUprn: string | null; + resolvedAddress: string | null; + markedNoUprn: boolean; + // Resolved = user picked a UPRN OR asserted there is none. + resolved: boolean; +} + +function parseS3Uri(uri: string): { bucket: string; key: string } { + const url = new URL(uri); // s3://bucket/key... + const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname; + if (!url.hostname || !key) throw new Error(`Malformed S3 URI: ${uri}`); + return { bucket: url.hostname, key }; +} + +async function readCombinerRows(s3Uri: string): Promise[]> { + const { bucket, key } = parseS3Uri(s3Uri); + const s3 = createRetrofitDataS3Client(); + const result = await s3.getObject({ Bucket: bucket, Key: key }).promise(); + const body = result.Body?.toString("utf-8"); + if (!body) return []; + const wb = XLSX.read(body, { type: "string" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + // Every cell as a string so a numeric UPRN never arrives as a float. + return XLSX.utils.sheet_to_json>(sheet, { defval: "", raw: false }); +} + +function buildAddress(row: Record): string { + return ["Address 1", "Address 2", "Address 3"] + .map((c) => (row[c] ?? "").trim()) + .filter(Boolean) + .join(", "); +} + +/** + * The combiner rows that need user confirmation, joined with any saved + * correction. Reads the combiner CSV from `combinedOutputS3Uri` (ADR-0057). + * Returns [] until the combiner has run. + */ +export async function getFlaggedAddresses( + uploadId: string, +): Promise { + const [row] = await db + .select({ combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri }) + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)); + if (!row?.combinedOutputS3Uri) return []; + + const [rows, corrections] = await Promise.all([ + readCombinerRows(row.combinedOutputS3Uri), + db + .select() + .from(bulkUploadUprnCorrections) + .where(eq(bulkUploadUprnCorrections.uploadId, uploadId)), + ]); + + const correctionByRow = new Map(corrections.map((c) => [c.sourceRowId, c])); + + return rows + .filter((r) => !isConfidentlyMatched(r)) + .map((r) => { + const sourceRowId = (r[SOURCE_ROW_ID_COLUMN] ?? "").trim(); + const uprn = (r[UPRN_COL] ?? "").trim(); + const rawScore = (r[LEXISCORE_COL] ?? "").trim(); + const lexiscore = rawScore === "" ? null : Number(rawScore); + const c = correctionByRow.get(sourceRowId); + const resolvedUprn = c?.uprn ?? null; + const markedNoUprn = c?.markedNoUprn ?? false; + return { + sourceRowId, + address: buildAddress(r), + postcode: (r["postcode"] ?? "").trim(), + status: (r[STATUS_COL] ?? (uprn ? "matched" : "unmatched")).trim(), + lexiscore: lexiscore !== null && Number.isFinite(lexiscore) ? lexiscore : null, + suggestedUprn: uprn || null, + resolvedUprn, + resolvedAddress: c?.address ?? null, + markedNoUprn, + resolved: resolvedUprn !== null || markedNoUprn, + }; + }); +} + +/** Count of flagged rows the user has not yet resolved — gates Finalise. */ +export async function countUnresolvedFlaggedAddresses(uploadId: string): Promise { + const flagged = await getFlaggedAddresses(uploadId); + return flagged.filter((f) => !f.resolved).length; +} + +const ADDRESS_COL = "address2uprn_address"; +const OVERLAY_STATUS_CONFIRMED = "user_confirmed"; +const OVERLAY_STATUS_NO_UPRN = "user_no_uprn"; + +/** + * Overlay the user's saved corrections onto the combiner CSV and write the + * result to a sibling `…​.confirmed.csv`, returning its S3 URI (ADR-0057). The + * finaliser reads this so a confirmed UPRN drives both the identity insert and + * property_overrides in one pass. No corrections ⇒ the original URI is returned + * unchanged (no rewrite). A `markedNoUprn` row keeps an empty UPRN (accepted + * terminal state — the finaliser leaves it as a no-UPRN property). + */ +export async function buildConfirmedCombinerUri( + uploadId: string, + combinedOutputS3Uri: string, +): Promise { + const corrections = await db + .select() + .from(bulkUploadUprnCorrections) + .where(eq(bulkUploadUprnCorrections.uploadId, uploadId)); + if (corrections.length === 0) return combinedOutputS3Uri; + const byRow = new Map(corrections.map((c) => [c.sourceRowId, c])); + + const { bucket, key } = parseS3Uri(combinedOutputS3Uri); + const s3 = createRetrofitDataS3Client(); + const result = await s3.getObject({ Bucket: bucket, Key: key }).promise(); + const body = result.Body?.toString("utf-8"); + if (!body) return combinedOutputS3Uri; + + const wb = XLSX.read(body, { type: "string" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + // Array-of-arrays so every column + its order is preserved exactly; we patch + // only the address2uprn cells of corrected rows. + const aoa = XLSX.utils.sheet_to_json(sheet, { + header: 1, + defval: "", + raw: false, + blankrows: false, + }); + const headers = (aoa[0] ?? []).map((h) => String(h)); + const iRow = headers.indexOf(SOURCE_ROW_ID_COLUMN); + const iUprn = headers.indexOf(UPRN_COL); + const iStatus = headers.indexOf(STATUS_COL); + const iAddr = headers.indexOf(ADDRESS_COL); + if (iRow < 0 || iUprn < 0) return combinedOutputS3Uri; // not an address2uprn CSV + + for (let r = 1; r < aoa.length; r++) { + const line = aoa[r]; + const srid = String(line[iRow] ?? "").trim(); + const c = byRow.get(srid); + if (!c) continue; + if (c.markedNoUprn) { + line[iUprn] = ""; + if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_NO_UPRN; + } else { + line[iUprn] = c.uprn ?? ""; + if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_CONFIRMED; + if (iAddr >= 0 && c.address) line[iAddr] = c.address; + } + } + + const csv = XLSX.utils.sheet_to_csv(XLSX.utils.aoa_to_sheet(aoa)); + const confirmedKey = `${key.replace(/\.csv$/i, "")}.confirmed.csv`; + await s3 + .putObject({ Bucket: bucket, Key: confirmedKey, Body: csv, ContentType: "text/csv" }) + .promise(); + return `s3://${bucket}/${confirmedKey}`; +} + +export type UprnCorrectionInput = + | { + sourceRowId: string; + uprn: string; + address: string; + postcode: string; + markedNoUprn?: false; + } + | { sourceRowId: string; markedNoUprn: true }; + +/** Upsert one combiner row's confirmation (assign a UPRN, or mark no-UPRN). */ +export async function upsertUprnCorrection( + uploadId: string, + input: UprnCorrectionInput, + userId?: string, +): Promise { + const values = input.markedNoUprn + ? { + uploadId, + sourceRowId: input.sourceRowId, + uprn: null, + address: null, + postcode: null, + markedNoUprn: true as const, + userId: userId ?? null, + } + : { + uploadId, + sourceRowId: input.sourceRowId, + uprn: input.uprn, + address: input.address, + postcode: input.postcode, + markedNoUprn: false as const, + userId: userId ?? null, + }; + + await db + .insert(bulkUploadUprnCorrections) + .values(values) + .onConflictDoUpdate({ + target: [ + bulkUploadUprnCorrections.uploadId, + bulkUploadUprnCorrections.sourceRowId, + ], + set: { + uprn: values.uprn, + address: values.address, + postcode: values.postcode, + markedNoUprn: values.markedNoUprn, + userId: values.userId, + updatedAt: new Date(), + }, + }); +} diff --git a/src/lib/bulkUpload/client.ts b/src/lib/bulkUpload/client.ts index 3f3a34e9..939d0f01 100644 --- a/src/lib/bulkUpload/client.ts +++ b/src/lib/bulkUpload/client.ts @@ -150,6 +150,74 @@ export function useSampleClassifications( }); } +// One combiner row that address2uprn could not confidently match (ADR-0057), +// plus any saved correction. Mirrors the server FlaggedAddressRow. +export interface FlaggedAddress { + sourceRowId: string; + address: string; + postcode: string; + status: string; + lexiscore: number | null; + suggestedUprn: string | null; + resolvedUprn: string | null; + resolvedAddress: string | null; + markedNoUprn: boolean; + resolved: boolean; +} + +export interface AddressMatchesView { + flagged: FlaggedAddress[]; + unresolved: number; +} + +export function useAddressMatches( + portfolioId: string, + uploadId: string, + enabled: boolean, +) { + return useQuery({ + queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"], + enabled, + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-matches`, + ); + if (!res.ok) throw await parseError(res, "Failed to load address matches."); + const body = await res.json(); + return { + flagged: (body.flagged ?? []) as FlaggedAddress[], + unresolved: Number(body.unresolved ?? 0), + }; + }, + }); +} + +export type SaveCorrectionInput = + | { sourceRowId: string; uprn: string; address: string; postcode: string } + | { sourceRowId: string; markedNoUprn: true }; + +export function useSaveUprnCorrection(portfolioId: string, uploadId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input) => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-corrections`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (!res.ok) throw await parseError(res, "Failed to save address."); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"], + }); + }, + }); +} + export function useConfirmMultiEntryOrdering(portfolioId: string, uploadId: string) { const queryClient = useQueryClient(); return useMutation }>({ diff --git a/src/lib/bulkUpload/server.ts b/src/lib/bulkUpload/server.ts index b442e0c3..7518fae0 100644 --- a/src/lib/bulkUpload/server.ts +++ b/src/lib/bulkUpload/server.ts @@ -16,6 +16,10 @@ import { retrofitDataS3Bucket } from "@/app/utils/s3"; import { SUBTASK_SERVICE } from "./types"; import type { MultiEntrySummary } from "./multiEntry"; import { isPermutation } from "./multiEntry"; +import { + buildConfirmedCombinerUri, + countUnresolvedFlaggedAddresses, +} from "./addressMatches"; const REMAP_ALLOWED: ReadonlySet = new Set([ "ready_for_processing", @@ -603,6 +607,7 @@ export type DispatchFinaliserOutcome = | { kind: "not_yet_combined" } | { kind: "wrong_state"; current: string } | { kind: "missing_task" } + | { kind: "unresolved_addresses"; count: number } | { kind: "trigger_failed"; status: number; message: string }; // Dispatch the async bulk_upload_finaliser (ADR-0005). Replaces the old @@ -631,6 +636,14 @@ export async function dispatchFinaliser(args: { const upload = guarded.upload; if (!upload.taskId) return { kind: "missing_task" }; + // ADR-0057: every combiner row address2uprn could not confidently match must + // be resolved on the Addresses tab (a chosen UPRN or an explicit no-UPRN) + // before finalise — otherwise a withheld row would land as an unintended + // no-UPRN property. Gate before the CAS claim so a blocked upload stays in + // awaiting_review. + const unresolved = await countUnresolvedFlaggedAddresses(args.uploadId); + if (unresolved > 0) return { kind: "unresolved_addresses", count: unresolved }; + // CAS: atomically claim the dispatch. Only the request that flips // awaiting_review → finalising proceeds; a concurrent one updates 0 rows. const claimed = await db @@ -674,10 +687,18 @@ export async function dispatchFinaliser(args: { ? `s3://${retrofitDataS3Bucket()}/${classifierCsvKey(upload.portfolioId, args.uploadId)}` : null; + // Overlay the user's confirmed UPRNs onto the combiner CSV and point the + // finaliser at the corrected copy (ADR-0057). No corrections ⇒ the original + // URI is returned unchanged. + const confirmedS3Uri = await buildConfirmedCombinerUri( + args.uploadId, + upload.combinedOutputS3Uri!, + ); + const payload = { task_id: upload.taskId, sub_task_id: subTask.id, - s3_uri: upload.combinedOutputS3Uri, + s3_uri: confirmedS3Uri, portfolio_id: Number(upload.portfolioId), bulk_upload_id: args.uploadId, classifier_s3_uri: classifierS3Uri, diff --git a/src/lib/modellingRuns/model.test.ts b/src/lib/modellingRuns/model.test.ts index 5bd39f8c..b37789bc 100644 --- a/src/lib/modellingRuns/model.test.ts +++ b/src/lib/modellingRuns/model.test.ts @@ -46,6 +46,24 @@ describe("normaliseRunFilters", () => { }); }); +describe("normaliseRunFilters — tags", () => { + it("normalises tag ids to a deduped, ascending number list", () => { + expect(normaliseRunFilters({ tagIds: [3, "1", 3, 2] })).toEqual({ + ok: true, + filters: { tagIds: [1, 2, 3] }, + }); + }); + + it("rejects a non-numeric or non-positive tag id", () => { + expect(normaliseRunFilters({ tagIds: [1, "x"] }).ok).toBe(false); + expect(normaliseRunFilters({ tagIds: [0] }).ok).toBe(false); + }); + + it("treats an absent/empty tag selection as unconstrained (no key)", () => { + expect(normaliseRunFilters({ tagIds: [] })).toEqual({ ok: true, filters: {} }); + }); +}); + describe("buildRunRecord", () => { it("produces the app-authored task row carrying the run config as its inputs (ADR-0008)", () => { const record = buildRunRecord({ @@ -76,6 +94,18 @@ describe("buildRunRecord", () => { filters: { postcodes: ["B93 8SU"], property_types: ["House"] }, }); }); + + it("carries tag ids into the task inputs, the dispatch payload, and back (ADR-0013)", () => { + const { task, dispatchPayload } = buildRunRecord({ + portfolioId: "5", + scenarioIds: ["2"], + filters: { tagIds: [7, 3] }, + previewedPropertyCount: 10, + triggeredBy: "u@x.io", + }); + expect(dispatchPayload.filters).toEqual({ tag_ids: [7, 3] }); + expect(parseRunConfig(task.inputs)?.filters.tagIds).toEqual([7, 3]); + }); }); describe("parseRunConfig", () => { @@ -113,6 +143,11 @@ describe("selectionSummary", () => { }), ).toBe("Postcodes: B93 8SU, M20 4TF · Type: House · Built form: Detached, Unknown"); }); + + it("summarises a tag selection as a count (names aren't in the pure filters)", () => { + expect(selectionSummary({ tagIds: [7, 3] })).toBe("Tags: 2"); + expect(selectionSummary({ tagIds: [7] })).toBe("Tags: 1"); + }); }); describe("deriveRunStatus", () => { diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts index 179af4a7..103858f9 100644 --- a/src/lib/modellingRuns/model.ts +++ b/src/lib/modellingRuns/model.ts @@ -21,6 +21,8 @@ export interface RunFilters { postcodes?: string[]; propertyTypes?: string[]; builtForms?: string[]; + /** Portfolio Tag ids (any-of); resolved against property_tag (ADR-0013). */ + tagIds?: number[]; } export type NormaliseFiltersResult = @@ -34,6 +36,7 @@ export function normaliseRunFilters(input: { postcodes?: string[]; propertyTypes?: string[]; builtForms?: string[]; + tagIds?: (number | string)[]; }): NormaliseFiltersResult { const filters: RunFilters = {}; if (input.postcodes?.length) { @@ -65,6 +68,20 @@ export function normaliseRunFilters(input: { } filters[key] = [...new Set(values)]; } + // Tags are dynamic (portfolio-specific), so there's no vocabulary to check + // against — just coerce to positive integer ids, dedupe and sort. Ids that + // don't belong to the portfolio simply match nothing at resolution time. + if (input.tagIds?.length) { + const ids = new Set(); + for (const raw of input.tagIds) { + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + return { ok: false, error: `Invalid tag '${raw}'` }; + } + ids.add(n); + } + filters.tagIds = [...ids].sort((a, b) => a - b); + } return { ok: true, filters }; } @@ -91,6 +108,7 @@ export function parseRunConfig(inputs: string | null): RunConfigView | null { if (raw.filters?.postcodes?.length) filters.postcodes = raw.filters.postcodes; if (raw.filters?.property_types?.length) filters.propertyTypes = raw.filters.property_types; if (raw.filters?.built_forms?.length) filters.builtForms = raw.filters.built_forms; + if (raw.filters?.tag_ids?.length) filters.tagIds = raw.filters.tag_ids; return { scenarioIds: raw.scenario_ids.map(String), filters, @@ -167,6 +185,9 @@ export function selectionSummary(filters: RunFilters): string { if (filters.postcodes?.length) bits.push(`Postcodes: ${filters.postcodes.join(", ")}`); if (filters.propertyTypes?.length) bits.push(`Type: ${filters.propertyTypes.join(", ")}`); if (filters.builtForms?.length) bits.push(`Built form: ${filters.builtForms.join(", ")}`); + // Tag names aren't carried in the pure filters (only ids), so summarise as a + // count; the run-history UI resolves names separately when it needs them. + if (filters.tagIds?.length) bits.push(`Tags: ${filters.tagIds.length}`); return bits.length ? bits.join(" · ") : "All properties"; } @@ -186,6 +207,7 @@ export interface RunConfigInputs { postcodes?: string[]; property_types?: string[]; built_forms?: string[]; + tag_ids?: number[]; }; previewed_property_count: number; triggered_by: string; @@ -212,6 +234,7 @@ export function buildRunRecord(req: RunRequest): { if (req.filters.postcodes) filters.postcodes = req.filters.postcodes; if (req.filters.propertyTypes) filters.property_types = req.filters.propertyTypes; if (req.filters.builtForms) filters.built_forms = req.filters.builtForms; + if (req.filters.tagIds) filters.tag_ids = req.filters.tagIds; const configInputs: RunConfigInputs = { portfolio_id: Number(req.portfolioId), scenario_ids: req.scenarioIds.map(Number), diff --git a/src/lib/modellingRuns/server.ts b/src/lib/modellingRuns/server.ts index 63f03c31..f6c77b01 100644 --- a/src/lib/modellingRuns/server.ts +++ b/src/lib/modellingRuns/server.ts @@ -11,7 +11,12 @@ import { parseRunConfig, selectionSummary, } from "./model"; -import { builtFormTypeSql, propertyTypeSql } from "@/lib/services/epcSources"; +import { + builtFormOverrideJoin, + propertyTypeOverrideJoin, + resolvedBuiltFormSql, + resolvedPropertyTypeSql, +} from "@/lib/services/epcSources"; // Distributor entry point on the Model backend. It fans the run out to // workers, attaching execution sub-tasks to the task_id we pass (ADR-0008). @@ -141,20 +146,27 @@ export async function previewModellingRun(args: { if (args.filters.builtForms) { conditions.push(sql`resolved.bform IN (${inList(args.filters.builtForms)})`); } + if (args.filters.tagIds) { + // Tags resolve by direct membership (any-of), not the override→EPC rule — + // ADR-0013. The distributor mirrors this join against the shared DB. + conditions.push( + sql`resolved.id IN (SELECT pt.property_id FROM property_tag pt WHERE pt.tag_id IN (${inList(args.filters.tagIds)}))`, + ); + } const result = await db.execute<{ matched: number; per_scenario: { scenario_id: string; n: number }[] | null; }>(sql` WITH resolved AS ( + -- Resolution rule shared with the portfolio list via epcSources fragments + -- so the preview and the list can never drift (ADR-0008, ADR-0012). SELECT p.id, p.postcode, - COALESCE(pot.override_value, ${propertyTypeSql}, 'Unknown') AS ptype, - COALESCE(pobf.override_value, ${builtFormTypeSql}, 'Unknown') AS bform + ${resolvedPropertyTypeSql} AS ptype, + ${resolvedBuiltFormSql} AS bform FROM property p - LEFT JOIN property_overrides pot ON pot.property_id = p.id - AND pot.override_component = 'property_type' AND pot.building_part = 0 - LEFT JOIN property_overrides pobf ON pobf.property_id = p.id - AND pobf.override_component = 'built_form_type' AND pobf.building_part = 0 + ${propertyTypeOverrideJoin} + ${builtFormOverrideJoin} LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged' LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted' WHERE p.portfolio_id = ${pid} AND p.marked_for_deletion = false diff --git a/src/lib/postcodeSearch/model.test.ts b/src/lib/postcodeSearch/model.test.ts index 3c1449eb..e18b6d9e 100644 --- a/src/lib/postcodeSearch/model.test.ts +++ b/src/lib/postcodeSearch/model.test.ts @@ -20,6 +20,13 @@ describe("classifyOsCode", () => { expect(classifyOsCode("RD07")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); }); + it("maps the RH (HMO) family to House, like RD07", () => { + // e.g. UPRN 100031252876, 7 Leyton Avenue — RH03 must not read as non-residential. + expect(classifyOsCode("RH01")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); + expect(classifyOsCode("RH02")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); + expect(classifyOsCode("RH03")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); + }); + it("keeps other residential codes selectable but writes no facts — never Unknown", () => { for (const code of ["RD", "RD01", "RD08"]) { expect(classifyOsCode(code)).toEqual({ selectable: true, propertyType: null, builtForm: null }); @@ -63,6 +70,21 @@ describe("toAddressCandidates", () => { expect(out[1]).toMatchObject({ uprn: "101", selectable: false }); }); + it("classifies an RH03 HMO as a selectable House, not Non-residential", () => { + // Regression: 7 Leyton Avenue (UPRN 100031252876) came back RH03 ("HMO Not + // Further Divided") on a street of RD02/RD03 houses and rendered as + // "Non-residential", so it couldn't be added. + const hmo = [ + { DPA: { UPRN: "100031252876", ADDRESS: "7, LEYTON AVENUE, SUTTON-IN-ASHFIELD, NG17 3BD", + POSTCODE: "NG17 3BD", CLASSIFICATION_CODE: "RH03", LAT: 53.1352313, LNG: -1.2522665 } }, + ]; + const out = toAddressCandidates(hmo as never, "NG17 3BD"); + expect(out[0]).toMatchObject({ + uprn: "100031252876", address: "7, Leyton Avenue, Sutton-In-Ashfield", + classificationCode: "RH03", selectable: true, propertyType: "House", builtForm: null, + }); + }); + it("orders addresses numerically, so Flat 2 comes before Flat 10", () => { const flats = [ { DPA: { UPRN: "1", ADDRESS: "FLAT 10, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } }, @@ -102,6 +124,18 @@ describe("buildWritePlan", () => { { component: "built_form_type", value: "Detached", originalDescription: "RD02", buildingPart: 0 }, ]); }); + it("writes a House property_type override for an RH03 HMO, keyed to the OS code", () => { + const plan = buildWritePlan( + { uprn: "100031252876", address: "7, Leyton Avenue, Sutton-In-Ashfield", postcode: "NG17 3BD", + classificationCode: "RH03", lat: 53.1352313, lng: -1.2522665, selectable: true, + propertyType: "House", builtForm: null }, + geo, + ); + // HMO built form is unknowable, so only the property_type fact is written. + expect(plan.overrides).toEqual([ + { component: "property_type", value: "House", originalDescription: "RH03", buildingPart: 0 }, + ]); + }); it("writes no overrides when the code maps nothing — never Unknown", () => { const plan = buildWritePlan( { uprn: "200", address: "Birch Court", postcode: "M20 4UD", classificationCode: "RD", diff --git a/src/lib/postcodeSearch/model.ts b/src/lib/postcodeSearch/model.ts index 289cdb2d..fd1c226e 100644 --- a/src/lib/postcodeSearch/model.ts +++ b/src/lib/postcodeSearch/model.ts @@ -28,7 +28,11 @@ const OS_CODE_FACTS: Record { + const body = await res.json().catch(() => ({})); + return new Error(body?.error ?? fallback); +} + +// One address candidate returned by the portfolio postcode search, plus the +// server-computed flag for candidates already assigned in this portfolio. +export interface SearchCandidate { + uprn: string; + address: string; + postcode: string; + classificationCode: string | null; + selectable: boolean; // residential (per OS classification) + propertyType: string | null; + builtForm: string | null; + alreadyInPortfolio: boolean; +} + +export interface AddressSearchResult { + postcode: string; + source: "cache" | "live"; + candidates: SearchCandidate[]; +} + +// Run the postcode → address-candidate search. Reuses the add-properties +// endpoint, which read-through caches OS Places results per postcode in the +// postcode_search table (30-day TTL) — so repeat searches of the same postcode +// hit our DB, not the OS API. Throws with the server's { error } on failure. +export async function searchAddresses( + portfolioId: string, + postcode: string, +): Promise { + const res = await fetch( + `/api/portfolio/${portfolioId}/properties/search?postcode=${encodeURIComponent(postcode)}`, + ); + if (!res.ok) throw await parseError(res, "Address search failed."); + return res.json(); +} + +export interface AssignUprnInput { + uprn: string; + address: string; + postcode: string; + userInputtedAddress?: string; + userInputtedPostcode?: string; +} + +// Assign a chosen candidate's UPRN + address to a property. +export function useAssignUprn(portfolioId: string, propertyId: string) { + return useMutation<{ ok: true }, Error, AssignUprnInput>({ + mutationFn: async (input) => { + const res = await fetch( + `/api/portfolio/${portfolioId}/properties/${propertyId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (!res.ok) throw await parseError(res, "Failed to assign UPRN."); + return res.json(); + }, + }); +} diff --git a/src/lib/properties/unmatched.ts b/src/lib/properties/unmatched.ts new file mode 100644 index 00000000..9f8e6334 --- /dev/null +++ b/src/lib/properties/unmatched.ts @@ -0,0 +1,47 @@ +import { db } from "@/app/db/db"; +import { property } from "@/app/db/schema/property"; +import { and, asc, eq, isNull } from "drizzle-orm"; + +export interface UnmatchedProperty { + id: string; + address: string | null; + postcode: string | null; +} + +// Properties in a portfolio that have no UPRN — i.e. were never matched to an +// Ordnance Survey address record. We surface these separately from the main +// property table so the customer can correct the address/postcode and re-run +// the address→UPRN search (the "advanced ARA search", wired up in a follow-up). +// `markedForDeletion` rows are excluded so soft-deleted properties don't show. +export async function getUnmatchedProperties( + portfolioId: string, +): Promise { + if (!/^\d+$/.test(portfolioId)) return []; + const pid = BigInt(portfolioId); + + const rows = await db + .select({ + id: property.id, + address: property.address, + postcode: property.postcode, + userInputtedAddress: property.userInputtedAddress, + userInputtedPostcode: property.userInputtedPostcode, + }) + .from(property) + .where( + and( + eq(property.portfolioId, pid), + isNull(property.uprn), + eq(property.markedForDeletion, false), + ), + ) + .orderBy(asc(property.address)); + + // Prefer the canonical address; fall back to whatever the user typed on + // import so the row is still identifiable while it awaits a match. + return rows.map((r) => ({ + id: String(r.id), + address: r.address ?? r.userInputtedAddress ?? null, + postcode: r.postcode ?? r.userInputtedPostcode ?? null, + })); +} diff --git a/src/lib/scenarios/model.test.ts b/src/lib/scenarios/model.test.ts index c901b236..c84e0207 100644 --- a/src/lib/scenarios/model.test.ts +++ b/src/lib/scenarios/model.test.ts @@ -197,6 +197,18 @@ describe("validateScenarioConfig", () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.errors.exclusions).toBeTruthy(); }); + + it("carries fabric first through when the scenario is fabric-first", () => { + const result = validateScenarioConfig({ ...validBase, exclusions: [], fabricFirst: true }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.fabricFirst).toBe(true); + }); + + it("defaults fabric first to false when the flag is absent", () => { + const result = validateScenarioConfig({ ...validBase, exclusions: [] }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.fabricFirst).toBe(false); + }); }); describe("scenarioToInsertRow", () => { @@ -241,14 +253,30 @@ describe("scenarioToInsertRow", () => { expect(row.budget).toBeNull(); expect(row.exclusions).toBeNull(); }); + + it("persists the fabric-first flag, defaulting to false", () => { + const off = validateScenarioConfig({ + name: "Reach EPC C", housingType: "Social", goal: "Increasing EPC", + goalValue: "C", budgetPerProperty: null, exclusions: [], + }); + const on = validateScenarioConfig({ + name: "Reach EPC C — fabric first", housingType: "Social", goal: "Increasing EPC", + goalValue: "C", budgetPerProperty: null, exclusions: [], fabricFirst: true, + }); + expect(off.ok && on.ok).toBe(true); + if (!off.ok || !on.ok) return; + expect(scenarioToInsertRow(off.value, 1n).fabricFirst).toBe(false); + expect(scenarioToInsertRow(on.value, 1n).fabricFirst).toBe(true); + }); }); describe("findExactDuplicate", () => { const existing = [ { id: 1n, name: "Reach EPC C", housingType: "Social", goal: "Increasing EPC", - goalValue: "C", budget: null, exclusions: "{external_wall_insulation,solid_floor_insulation}" }, + goalValue: "C", budget: null, exclusions: "{external_wall_insulation,solid_floor_insulation}", + fabricFirst: false }, { id: 2n, name: "Max CO₂", housingType: "Social", goal: "Reducing CO2 emissions", - goalValue: null, budget: 6000, exclusions: null }, + goalValue: null, budget: 6000, exclusions: null, fabricFirst: false }, ]; const config = { name: "A totally different name", @@ -271,6 +299,18 @@ describe("findExactDuplicate", () => { findExactDuplicate({ ...config, exclusions: ["solid_floor_insulation"] }, existing), ).toBeNull(); }); + + it("treats fabric first as part of the configuration identity", () => { + // id 1 is not fabric-first; the same config asked fabric-first is a distinct question. + expect(findExactDuplicate({ ...config, fabricFirst: true }, existing)).toBeNull(); + // ...and matches once both sides agree it is fabric-first. + const fabricFirstExisting = existing.map((s) => + s.id === 1n ? { ...s, fabricFirst: true } : s, + ); + expect( + findExactDuplicate({ ...config, fabricFirst: true }, fabricFirstExisting)?.id, + ).toBe(1n); + }); }); describe("DISRUPTIVE_MEASURES", () => { diff --git a/src/lib/scenarios/model.ts b/src/lib/scenarios/model.ts index fe2e3820..56639302 100644 --- a/src/lib/scenarios/model.ts +++ b/src/lib/scenarios/model.ts @@ -75,6 +75,8 @@ export interface ScenarioConfigInput { goalValue: string | null; budgetPerProperty: number | null; exclusions: string[]; + /** Fabric-before-heating ordering constraint (CONTEXT.md). Off by default. */ + fabricFirst?: boolean; } export type ValidationResult = @@ -128,7 +130,10 @@ export function validateScenarioConfig( } if (Object.keys(errors).length > 0) return { ok: false, errors }; - return { ok: true, value: { ...input, name, goalValue, exclusions } }; + return { + ok: true, + value: { ...input, name, goalValue, exclusions, fabricFirst: input.fabricFirst ?? false }, + }; } /** @@ -162,6 +167,7 @@ export function scenarioToInsertRow( exclusions: serializeExclusions(value.exclusions), multiPlan: true, isDefault: false, + fabricFirst: value.fabricFirst ?? false, }; } @@ -233,17 +239,20 @@ export interface ExistingScenarioConfig { goalValue: string | null; budget: number | null; exclusions: string | null; + fabricFirst: boolean; } /** - * Exact-configuration duplicate: same goal, goal value, housing type, budget - * and exclusion set. The name is a label and is deliberately ignored — - * an identically-configured scenario would model to identical plans. + * Exact-configuration duplicate: same goal, goal value, housing type, budget, + * exclusion set and fabric-first flag. The name is a label and is deliberately + * ignored — an identically-configured scenario would model to identical plans, + * so any facet that changes the modelled outcome (fabric-first among them) + * makes it a distinct question. */ export function findExactDuplicate( config: Pick< ScenarioConfigInput, - "housingType" | "goal" | "goalValue" | "budgetPerProperty" | "exclusions" + "housingType" | "goal" | "goalValue" | "budgetPerProperty" | "exclusions" | "fabricFirst" >, existing: ExistingScenarioConfig[], ): ExistingScenarioConfig | null { @@ -255,6 +264,7 @@ export function findExactDuplicate( (s.goalValue ?? null) === (config.goalValue ?? null) && s.housingType === config.housingType && (s.budget ?? null) === (config.budgetPerProperty ?? null) && + (s.fabricFirst ?? false) === (config.fabricFirst ?? false) && parseExclusions(s.exclusions).sort().join(",") === wanted, ) ?? null ); diff --git a/src/lib/scenarios/queries.ts b/src/lib/scenarios/queries.ts index 74b0de9f..f6e2a12c 100644 --- a/src/lib/scenarios/queries.ts +++ b/src/lib/scenarios/queries.ts @@ -24,6 +24,7 @@ export interface ScenarioListItem { goalValue: string | null; budget: number | null; exclusions: string[]; + fabricFirst: boolean; createdAt: Date; plansCount: number; status: ScenarioStatus; @@ -56,6 +57,7 @@ export async function listScenariosWithStatus( goalValue: s.goalValue, budget: s.budget, exclusions: parseExclusions(s.exclusions), + fabricFirst: s.fabricFirst, createdAt: s.createdAt, plansCount, status: deriveScenarioStatus(plansCount), @@ -82,6 +84,7 @@ export async function getScenarioWithPlansCount( goalValue: row.goalValue, budget: row.budget, exclusions: parseExclusions(row.exclusions), + fabricFirst: row.fabricFirst, createdAt: row.createdAt, plansCount, status: deriveScenarioStatus(plansCount), diff --git a/src/lib/services/descriptorResolution.test.ts b/src/lib/services/descriptorResolution.test.ts new file mode 100644 index 00000000..1c487586 --- /dev/null +++ b/src/lib/services/descriptorResolution.test.ts @@ -0,0 +1,521 @@ +import { describe, expect, it } from "vitest"; +import { + resolveBuiltForm, + resolveConstructionAge, + resolveMainFuel, + resolveOverrideDescriptor, + resolvePropertyType, +} from "./descriptorResolution"; +import { + MAINFUEL_OPTIONS, + RDSAP_MAIN_FUEL_CODE_LABELS, + EPR_MAIN_FUEL_STRING_LABELS, + WALL_TYPE_OPTIONS, + ROOF_TYPE_OPTIONS, + HEATING_SYSTEM_OPTIONS, + DESCRIPTOR_FILTER_CONFIG, + classifyDescriptor, +} from "@/app/utils/propertyFilters"; +import { + MainFuelValues, + WallTypeValues, + RoofTypeValues, + MainHeatingSystemValues, +} from "@/app/db/schema/landlord_overrides"; + +describe("resolvePropertyType", () => { + it("prefers a landlord override over the EPC-derived value", () => { + expect( + resolvePropertyType({ + override: "Maisonette", + isNewApproach: true, + lodgedPropertyType: "2", // EPC code for Flat + lodgedDwellingType: null, + predictedPropertyType: null, + predictedDwellingType: null, + legacyPropertyType: null, + }), + ).toBe("Maisonette"); + }); + + it("maps an RdSAP property-type code to its label when there is no override", () => { + expect( + resolvePropertyType({ + override: null, + isNewApproach: true, + lodgedPropertyType: "0", // RdSAP code for House + lodgedDwellingType: null, + predictedPropertyType: null, + predictedDwellingType: null, + legacyPropertyType: null, + }), + ).toBe("House"); + }); + + it("falls back to the predicted EPC when there is no lodged value", () => { + expect( + resolvePropertyType({ + override: null, + isNewApproach: true, + lodgedPropertyType: null, + lodgedDwellingType: null, + predictedPropertyType: "2", // RdSAP code for Flat + predictedDwellingType: null, + legacyPropertyType: null, + }), + ).toBe("Flat"); + }); + + it("prefers the lodged EPC over the predicted EPC when both exist", () => { + expect( + resolvePropertyType({ + override: null, + isNewApproach: true, + lodgedPropertyType: "0", // House + lodgedDwellingType: null, + predictedPropertyType: "2", // Flat + predictedDwellingType: null, + legacyPropertyType: null, + }), + ).toBe("House"); + }); + + it("falls back to dwelling_type when the lodged property_type is absent", () => { + expect( + resolvePropertyType({ + override: null, + isNewApproach: true, + lodgedPropertyType: null, + lodgedDwellingType: "Flat", + predictedPropertyType: null, + predictedDwellingType: null, + legacyPropertyType: null, + }), + ).toBe("Flat"); + }); + + it("reads the legacy property-row value for a legacy property", () => { + expect( + resolvePropertyType({ + override: null, + isNewApproach: false, + lodgedPropertyType: "0", // must be ignored for legacy + lodgedDwellingType: null, + predictedPropertyType: null, + predictedDwellingType: null, + legacyPropertyType: "Bungalow", + }), + ).toBe("Bungalow"); + }); + + it("never reads the EPC graph nor the legacy row across the cutoff (new-approach ignores the legacy row)", () => { + expect( + resolvePropertyType({ + override: null, + isNewApproach: true, + lodgedPropertyType: null, + lodgedDwellingType: null, + predictedPropertyType: null, + predictedDwellingType: null, + legacyPropertyType: "Bungalow", // legacy row must NOT surface for a new-approach property + }), + ).toBe("Unknown"); + }); + + // Spec locks for decisions taken in ADR-0012 — an override always wins (even an + // explicit "Unknown"), and an unrecognised RdSAP code is passed through as-is. + it("lets an explicit 'Unknown' override win over a known EPC value", () => { + expect( + resolvePropertyType({ + override: "Unknown", + isNewApproach: true, + lodgedPropertyType: "0", // EPC knows "House" — the override still wins + lodgedDwellingType: null, + predictedPropertyType: null, + predictedDwellingType: null, + legacyPropertyType: null, + }), + ).toBe("Unknown"); + }); + + it("passes an unrecognised RdSAP code through unchanged", () => { + expect( + resolvePropertyType({ + override: null, + isNewApproach: true, + lodgedPropertyType: "9", // not in PROPERTY_TYPE_LABELS + lodgedDwellingType: null, + predictedPropertyType: null, + predictedDwellingType: null, + legacyPropertyType: null, + }), + ).toBe("9"); + }); +}); + +describe("resolveBuiltForm", () => { + it("prefers a landlord override over the EPC-derived value", () => { + expect( + resolveBuiltForm({ + override: "Detached", + isNewApproach: true, + lodgedBuiltForm: "4", // EPC code for Mid-Terrace + predictedBuiltForm: null, + legacyBuiltForm: null, + }), + ).toBe("Detached"); + }); + + it("maps an RdSAP built-form code to its label when there is no override", () => { + expect( + resolveBuiltForm({ + override: null, + isNewApproach: true, + lodgedBuiltForm: "2", // RdSAP code for Semi-Detached + predictedBuiltForm: null, + legacyBuiltForm: null, + }), + ).toBe("Semi-Detached"); + }); + + it("falls back to the predicted EPC when there is no lodged value", () => { + expect( + resolveBuiltForm({ + override: null, + isNewApproach: true, + lodgedBuiltForm: null, + predictedBuiltForm: "1", // Detached + legacyBuiltForm: null, + }), + ).toBe("Detached"); + }); + + it("reads the legacy property-row value for a legacy property", () => { + expect( + resolveBuiltForm({ + override: null, + isNewApproach: false, + lodgedBuiltForm: "1", // must be ignored for legacy + predictedBuiltForm: null, + legacyBuiltForm: "End-Terrace", + }), + ).toBe("End-Terrace"); + }); + + it("resolves to 'Unknown' when nothing is available (never null)", () => { + expect( + resolveBuiltForm({ + override: null, + isNewApproach: true, + lodgedBuiltForm: null, + predictedBuiltForm: null, + legacyBuiltForm: "Detached", // legacy row must NOT surface for new-approach + }), + ).toBe("Unknown"); + }); +}); + +describe("resolveConstructionAge", () => { + it("maps a landlord override band letter to its band label", () => { + expect( + resolveConstructionAge({ + override: "B", + isNewApproach: true, + lodgedAgeBand: "F", // EPC says a different band — override still wins + predictedAgeBand: null, + legacyYearBuilt: null, + }), + ).toBe("1900–1929"); + }); + + it("maps the lodged EPC band letter to its label when there is no override", () => { + expect( + resolveConstructionAge({ + override: null, + isNewApproach: true, + lodgedAgeBand: "C", + predictedAgeBand: null, + legacyYearBuilt: null, + }), + ).toBe("1930–1949"); + }); + + it("falls back to the predicted EPC band when there is no lodged band", () => { + expect( + resolveConstructionAge({ + override: null, + isNewApproach: true, + lodgedAgeBand: null, + predictedAgeBand: "M", + legacyYearBuilt: null, + }), + ).toBe("2023 onwards"); + }); + + it("buckets a legacy year_built into its band (mid-range)", () => { + expect(legacyAge("1985")).toBe("1983–1990"); + }); + + it("buckets a legacy year before 1900 into band A", () => { + expect(legacyAge("1899")).toBe("before 1900"); + }); + + it("buckets the first year of a band correctly (boundary)", () => { + expect(legacyAge("1900")).toBe("1900–1929"); + }); + + it("buckets a recent legacy year into the open-ended final band", () => { + expect(legacyAge("2025")).toBe("2023 onwards"); + }); + + it("resolves a non-numeric legacy year_built to 'Unknown'", () => { + expect(legacyAge("N/A")).toBe("Unknown"); + }); + + it("resolves to 'Unknown' when nothing is available; ignores the legacy row for new-approach", () => { + expect( + resolveConstructionAge({ + override: null, + isNewApproach: true, + lodgedAgeBand: null, + predictedAgeBand: null, + legacyYearBuilt: "1985", // must NOT surface for a new-approach property + }), + ).toBe("Unknown"); + }); + + it("lets an explicit 'Unknown' override win", () => { + expect( + resolveConstructionAge({ + override: "Unknown", + isNewApproach: true, + lodgedAgeBand: "C", + predictedAgeBand: null, + legacyYearBuilt: null, + }), + ).toBe("Unknown"); + }); +}); + +describe("resolveMainFuel", () => { + const base = { + override: null, + isNewApproach: true, + lodgedMainFuel: null, + predictedMainFuel: null, + legacyMainFuel: null, + }; + + it("prefers a landlord override over the EPC-derived value", () => { + expect( + resolveMainFuel({ ...base, override: "mains gas", lodgedMainFuel: "26" }), + ).toBe("mains gas"); + }); + + it("reads the new-approach EPC main-fuel code (lodged) as a raw value", () => { + expect(resolveMainFuel({ ...base, lodgedMainFuel: "26" })).toBe("26"); + }); + + it("falls back to the predicted EPC main fuel when there is no lodged one", () => { + expect(resolveMainFuel({ ...base, predictedMainFuel: "29" })).toBe("29"); + }); + + it("prefers the lodged EPC main fuel over the predicted one", () => { + expect( + resolveMainFuel({ ...base, lodgedMainFuel: "26", predictedMainFuel: "29" }), + ).toBe("26"); + }); + + it("passes an EPR string main fuel through unchanged", () => { + expect(resolveMainFuel({ ...base, lodgedMainFuel: "Mains gas" })).toBe("Mains gas"); + }); + + it("resolves to 'Unknown' for a new-approach property with no EPC main fuel; ignores the legacy row", () => { + expect( + resolveMainFuel({ ...base, legacyMainFuel: "Gas mains gas" }), + ).toBe("Unknown"); + }); + + it("reads the legacy EPC main fuel for a legacy property", () => { + expect( + resolveMainFuel({ + ...base, + isNewApproach: false, + lodgedMainFuel: "26", // must be ignored for legacy + legacyMainFuel: "Gas mains gas", + }), + ).toBe("Gas mains gas"); + }); + + it("resolves to 'Unknown' when nothing is available (never null)", () => { + expect(resolveMainFuel({ ...base, isNewApproach: false })).toBe("Unknown"); + }); +}); + +describe("MAINFUEL_OPTIONS coverage", () => { + const covered = new Set(MAINFUEL_OPTIONS.flatMap((o) => o.dbValues)); + const labels = new Set(MAINFUEL_OPTIONS.map((o) => o.label)); + + // Every landlord main-fuel override value must fold into a filter option, or an + // overridden fuel would display but be unfilterable (ADR-0012). + it("covers every landlord main-fuel override value", () => { + for (const value of MainFuelValues) { + expect(covered).toContain(value); + } + }); + + // Every RdSAP main_fuel code the new EPC graph can store must fold into a + // filter option under a real label (a typo'd label would be silently dropped + // by the MAINFUEL_OPTIONS merge — assert both the code AND its label). + it("folds every RdSAP main_fuel code under a real label", () => { + for (const [code, label] of Object.entries(RDSAP_MAIN_FUEL_CODE_LABELS)) { + expect(labels).toContain(label); + expect(covered).toContain(code); + } + }); + + // Every EPR free-text main_fuel string must fold in too. + it("folds every EPR main-fuel string under a real label", () => { + for (const [str, label] of Object.entries(EPR_MAIN_FUEL_STRING_LABELS)) { + expect(labels).toContain(label); + expect(covered).toContain(str); + } + }); +}); + +describe("resolveOverrideDescriptor (wall / roof / heating free-text)", () => { + const base = { + override: null, + isNewApproach: true, + lodgedDescription: null, + predictedDescription: null, + legacyDescription: null, + }; + + it("prefers a landlord override over the EPC description", () => { + expect( + resolveOverrideDescriptor({ + ...base, + override: "Cavity wall, filled cavity", + lodgedDescription: "Solid brick, as built, no insulation (assumed)", + }), + ).toBe("Cavity wall, filled cavity"); + }); + + it("uses the lodged EPC description when there is no override", () => { + expect( + resolveOverrideDescriptor({ + ...base, + lodgedDescription: "Pitched, 250 mm loft insulation", + }), + ).toBe("Pitched, 250 mm loft insulation"); + }); + + it("falls back to the predicted EPC description when there is no lodged one", () => { + expect( + resolveOverrideDescriptor({ ...base, predictedDescription: "Flat, insulated (assumed)" }), + ).toBe("Flat, insulated (assumed)"); + }); + + it("reads the legacy description for a legacy property", () => { + expect( + resolveOverrideDescriptor({ + ...base, + isNewApproach: false, + lodgedDescription: "ignored for legacy", + legacyDescription: "Boiler and radiators, mains gas", + }), + ).toBe("Boiler and radiators, mains gas"); + }); + + it("resolves to 'Unknown' when nothing is available; ignores legacy for new-approach", () => { + expect( + resolveOverrideDescriptor({ ...base, legacyDescription: "must not surface" }), + ).toBe("Unknown"); + }); +}); + +describe("classifyDescriptor — wall / roof / heating buckets", () => { + const wall = (v: string) => + classifyDescriptor(v, WALL_TYPE_OPTIONS, DESCRIPTOR_FILTER_CONFIG.wallType.match); + const roof = (v: string) => + classifyDescriptor(v, ROOF_TYPE_OPTIONS, DESCRIPTOR_FILTER_CONFIG.roofType.match); + const heat = (v: string) => + classifyDescriptor(v, HEATING_SYSTEM_OPTIONS, DESCRIPTOR_FILTER_CONFIG.heatingSystem.match); + + // Every landlord override value must fall into a real (non-Unknown) bucket, or + // an overridden component would display but be unfilterable (ADR-0012). The + // literal "Unknown" is the sole exception — it maps to the Unknown bucket. + const assertVocabCovered = ( + values: readonly string[], + classify: (v: string) => string, + labels: Set, + ) => { + for (const value of values) { + const bucket = classify(value); + expect(labels).toContain(bucket); + if (value !== "Unknown") expect(bucket).not.toBe("Unknown"); + } + }; + + it("buckets every WallType override value", () => { + assertVocabCovered(WallTypeValues, wall, new Set(WALL_TYPE_OPTIONS.map((b) => b.label))); + }); + it("buckets every RoofType override value", () => { + assertVocabCovered(RoofTypeValues, roof, new Set(ROOF_TYPE_OPTIONS.map((b) => b.label))); + }); + it("buckets every MainHeatingSystem override value", () => { + assertVocabCovered( + MainHeatingSystemValues, + heat, + new Set(HEATING_SYSTEM_OPTIONS.map((b) => b.label)), + ); + }); + + // Representative EPC / legacy descriptions (from production) bucket as expected. + it("buckets representative EPC/legacy WALL descriptions", () => { + expect(wall("Cavity wall, filled cavity")).toBe("Cavity"); + expect(wall("Solid brick, as built, no insulation (assumed)")).toBe("Solid Brick"); + expect(wall("Timber frame, as built, insulated (assumed)")).toBe("Timber Frame"); + expect(wall("Sandstone or limestone, as built, no insulation (assumed)")).toBe("Stone"); + expect(wall("Granite or whinstone, as built, no insulation (assumed)")).toBe("Stone"); + expect(wall("System built, with external insulation")).toBe("System Built"); + // A bare U-value is not a construction type → Unknown. + expect(wall("Average thermal transmittance 0.21 W/m²K")).toBe("Unknown"); + }); + + it("buckets representative EPC/legacy ROOF descriptions", () => { + expect(roof("Pitched, 200 mm loft insulation")).toBe("Pitched"); + expect(roof("Flat, no insulation (assumed)")).toBe("Flat"); + expect(roof("Roof room(s), insulated (assumed)")).toBe("Room-in-Roof"); + expect(roof("(another dwelling above)")).toBe("Premises Above"); + expect(roof("(other premises above)")).toBe("Premises Above"); + expect(roof("Another Premises Above")).toBe("Premises Above"); + expect(roof("Average thermal transmittance 0.11 W/m²K")).toBe("Unknown"); + }); + + it("buckets representative EPC/legacy HEATING descriptions", () => { + expect(heat("Boiler and radiators, mains gas")).toBe("Gas Boiler"); + expect(heat("Room heaters, mains gas")).toBe("Gas Room Heater"); + expect(heat("Community scheme")).toBe("Community"); + expect(heat("Community scheme, biomass")).toBe("Community"); + expect(heat("Electric storage heaters")).toBe("Electric Storage"); + expect(heat("Room heaters, electric")).toBe("Direct Electric"); + expect(heat("Air source heat pump, radiators, electric")).toBe("Heat Pump"); + // "oil" is a substring of "boiler" — a gas boiler must NOT bucket as Oil. + expect(heat("Boiler and radiators, oil")).toBe("Oil"); + expect(heat("Boiler and radiators, dual fuel (mineral and wood)")).toBe("Solid Fuel"); + expect(heat("Boiler and radiators, lpg")).toBe("LPG"); + }); +}); + +/** Legacy-property helper: only year_built is set, cutoff is false. */ +function legacyAge(year: string): string { + return resolveConstructionAge({ + override: null, + isNewApproach: false, + lodgedAgeBand: null, + predictedAgeBand: null, + legacyYearBuilt: year, + }); +} diff --git a/src/lib/services/descriptorResolution.ts b/src/lib/services/descriptorResolution.ts new file mode 100644 index 00000000..da166b64 --- /dev/null +++ b/src/lib/services/descriptorResolution.ts @@ -0,0 +1,179 @@ +/** + * Pure precedence resolvers for the portfolio-list property descriptors. + * + * Mirrors the provenance.ts split: the DB query gathers the raw inputs (landlord + * override snapshot, lodged/predicted EPC values, legacy property-row value) and + * these functions own the precedence + RdSAP code→label mapping. The SQL twins + * in epcSources.ts (resolvedPropertyTypeSql, …) mirror this logic — keep in sync. + * + * Precedence (ADR-0008, extended to the list by ADR-0012): + * landlord override → EPC-derived (lodged over predicted, codes mapped; + * legacy property-row fallback) → 'Unknown' (never null) + */ +import { + BUILT_FORM_LABELS, + CONSTRUCTION_AGE_BANDS, + PROPERTY_TYPE_LABELS, +} from "./epcSources"; + +export interface PropertyTypeInputs { + /** property_overrides.override_value for component 'property_type', building_part 0. */ + override: string | null; + /** Whether the property reads the new EPC graph (updated_at >= cutoff). */ + isNewApproach: boolean; + /** epc_property(source='lodged').property_type — RdSAP code or text. */ + lodgedPropertyType: string | null; + /** epc_property(source='lodged').dwelling_type — fallback when property_type is absent. */ + lodgedDwellingType: string | null; + predictedPropertyType: string | null; + predictedDwellingType: string | null; + /** property.property_type — the legacy row value. */ + legacyPropertyType: string | null; +} + +export function resolvePropertyType(i: PropertyTypeInputs): string { + if (i.override) return i.override; + const derived = i.isNewApproach + ? epcPropertyType(i.lodgedPropertyType, i.lodgedDwellingType) ?? + epcPropertyType(i.predictedPropertyType, i.predictedDwellingType) + : i.legacyPropertyType; + return derived ?? "Unknown"; +} + +/** + * One EPC source's property-type label: the RdSAP code mapped to a label (text + * passes through), falling back to dwelling_type. Mirrors epcPropertyTypeSql. + */ +function epcPropertyType( + propertyType: string | null, + dwellingType: string | null, +): string | null { + if (propertyType != null) return PROPERTY_TYPE_LABELS[propertyType] ?? propertyType; + return dwellingType; +} + +export interface BuiltFormInputs { + /** property_overrides.override_value for component 'built_form_type', building_part 0. */ + override: string | null; + isNewApproach: boolean; + /** epc_property(source='lodged').built_form — RdSAP code or text. */ + lodgedBuiltForm: string | null; + predictedBuiltForm: string | null; + /** property.built_form — the legacy row value. */ + legacyBuiltForm: string | null; +} + +export function resolveBuiltForm(i: BuiltFormInputs): string { + if (i.override) return i.override; + const derived = i.isNewApproach + ? epcBuiltForm(i.lodgedBuiltForm) ?? epcBuiltForm(i.predictedBuiltForm) + : i.legacyBuiltForm; + return derived ?? "Unknown"; +} + +/** One EPC source's built-form label: RdSAP code → label, text passes through. Mirrors builtFormTypeSql. */ +function epcBuiltForm(builtForm: string | null): string | null { + if (builtForm == null) return null; + return BUILT_FORM_LABELS[builtForm] ?? builtForm; +} + +export interface ConstructionAgeInputs { + /** property_overrides.override_value for 'construction_age_band', building_part 0 — an RdSAP letter (A..M) or "Unknown". */ + override: string | null; + isNewApproach: boolean; + /** The main building part's epc_building_part.construction_age_band — an RdSAP letter. */ + lodgedAgeBand: string | null; + predictedAgeBand: string | null; + /** property.year_built — a raw year string (legacy). Bucketed into a band. */ + legacyYearBuilt: string | null; +} + +/** + * Construction age as an RdSAP band LABEL ("before 1900" … "2023 onwards", + * "Unknown") — the band is the underlying datum, never a single year (ADR-0012). + * Override and EPC both store the band letter; legacy year_built is bucketed into + * a band. Mirrors resolvedConstructionAgeSql. + */ +export function resolveConstructionAge(i: ConstructionAgeInputs): string { + if (i.override) return bandLetterToLabel(i.override); + const band = i.isNewApproach + ? i.lodgedAgeBand ?? i.predictedAgeBand + : bucketLegacyYearToBand(i.legacyYearBuilt); + return band != null ? bandLetterToLabel(band) : "Unknown"; +} + +/** RdSAP band letter → band label; unrecognised values (e.g. "Unknown") pass through. */ +function bandLetterToLabel(letter: string): string { + return CONSTRUCTION_AGE_BANDS[letter]?.label ?? letter; +} + +/** + * Bucket a legacy raw year_built into its RdSAP band letter: the band with the + * greatest start year not after the given year. Non-numeric text → null (Unknown); + * a year below the earliest start is pre-1900 → band A. Mirrors the legacy branch + * of resolvedConstructionAgeSql. + */ +export interface OverrideDescriptorInputs { + /** property_overrides.override_value for this component, building_part 0. */ + override: string | null; + isNewApproach: boolean; + /** epc_energy_element.description for the element on the lodged EPC. */ + lodgedDescription: string | null; + predictedDescription: string | null; + /** The legacy property_details_epc column (walls / roof / heating). */ + legacyDescription: string | null; +} + +/** + * Free-text component descriptor (wall type / roof type / heating system): + * override → EPC-derived (lodged over predicted, from epc_energy_element) → + * legacy property_details_epc column → 'Unknown'. No code→label mapping — the + * value is the description string itself. Mirrors resolvedWallTypeSql etc. + */ +export function resolveOverrideDescriptor(i: OverrideDescriptorInputs): string { + if (i.override) return i.override; + const derived = i.isNewApproach + ? i.lodgedDescription ?? i.predictedDescription + : i.legacyDescription; + return derived ?? "Unknown"; +} + +export interface MainFuelInputs { + /** property_overrides.override_value for component 'main_fuel', building_part 0. */ + override: string | null; + isNewApproach: boolean; + /** epc_main_heating_detail.main_fuel_type scalar on the lodged EPC — an RdSAP code ("26") or an EPR string ("Mains gas"). */ + lodgedMainFuel: string | null; + predictedMainFuel: string | null; + /** property_details_epc.mainfuel — the raw legacy EPC string. */ + legacyMainFuel: string | null; +} + +/** + * Main fuel raw value (folded to a label by the cell's resolveEnumLabel): + * override → EPC-derived (new: epc_main_heating_detail scalar, lodged over + * predicted; legacy: the property_details_epc string) → 'Unknown'. The + * new-approach scalar is an RdSAP `main_fuel` code or an EPR string; both fold + * into MAINFUEL_OPTIONS. Never null → 'Unknown'. Mirrors resolvedMainFuelSql. + */ +export function resolveMainFuel(i: MainFuelInputs): string { + if (i.override) return i.override; + const derived = i.isNewApproach + ? i.lodgedMainFuel ?? i.predictedMainFuel + : i.legacyMainFuel; + return derived ?? "Unknown"; +} + +function bucketLegacyYearToBand(yearBuilt: string | null): string | null { + if (yearBuilt == null || !/^[0-9]+$/.test(yearBuilt)) return null; + const year = parseInt(yearBuilt, 10); + let best: string | null = null; + let bestStart = -Infinity; + for (const [letter, { year: start }] of Object.entries(CONSTRUCTION_AGE_BANDS)) { + if (start <= year && start > bestStart) { + best = letter; + bestStart = start; + } + } + return best ?? "A"; +} diff --git a/src/lib/services/epcSlot.test.ts b/src/lib/services/epcSlot.test.ts new file mode 100644 index 00000000..bd2679a7 --- /dev/null +++ b/src/lib/services/epcSlot.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { + newApproachJoins, + estimatedSql, + withoutEpcSql, + isExpiredSql, + provenanceSignalSql, +} from "./epcSources"; + +/** + * `epc_property.source` has three values: `lodged`, and the two flavours of the + * predicted slot — `predicted` (gap-filled blind) and `expired` (gap-filled, but + * conditioned on the dwelling's own expired pre-2012 certificate; Model ADR-0054). + * + * These tests exist because of a specific, silent failure: every EPC expression + * used to match `source = 'predicted'` alone. An `expired` row then matched + * neither the lodged nor the predicted alias, so the home read as though it had a + * valid current certificate and vanished from BOTH the "Homes Without an EPC" and + * "Expired EPCs" cards. `epc_property.source` is plain `text` with no enum or CHECK + * constraint, so nothing at the database level would have caught it. + * + * They assert on rendered SQL rather than query results because the repo has no + * database harness — structural, but they pin the exact invariant that broke. + */ + +const dialect = new PgDialect(); +const render = (fragment: ReturnType) => + dialect.sqlToQuery(fragment).sql; + +// A zero-interpolation fragment renders empty on its own, so embed it. +const renderJoins = () => render(sql`SELECT 1 ${newApproachJoins}`); + +describe("newApproachJoins", () => { + it("joins all three sources, so no epc_property row is invisible", () => { + const joins = renderJoins(); + expect(joins).toContain("epl.source = 'lodged'"); + expect(joins).toContain("epp.source = 'predicted'"); + expect(joins).toContain("epx.source = 'expired'"); + }); +}); + +describe("estimatedSql — 'was this picture modelled?'", () => { + it("counts BOTH flavours of the predicted slot as estimated", () => { + // An expired-conditioned prediction is still a prediction: it must carry the + // Estimated provenance signal, not read as a real certificate. + expect(render(estimatedSql(sql`e`))).toContain( + "(epl.id IS NULL AND (epp.id IS NOT NULL OR epx.id IS NOT NULL))", + ); + }); +}); + +describe("withoutEpcSql — 'does any certificate exist?'", () => { + it("requires neither a lodged nor a historical record", () => { + expect(render(withoutEpcSql(sql`e`))).toContain( + "(epl.id IS NULL AND epx.id IS NULL)", + ); + }); + + it("does not consult the blind-predicted row: a prediction is not a certificate", () => { + // A home with only a `predicted` row has no EPC anywhere — that is precisely + // what makes it "without an EPC", so `epp` must not appear as evidence of one. + expect(render(withoutEpcSql(sql`e`))).not.toContain("epp"); + }); + + it("is a different question from estimatedSql", () => { + // The bug was treating these as synonyms. An `expired` home is estimated AND + // has an EPC, so the two predicates must disagree about it. + expect(render(withoutEpcSql(sql`e`))).not.toEqual(render(estimatedSql(sql`e`))); + }); +}); + +describe("isExpiredSql — 'we found a certificate and it is out of date'", () => { + it("treats an expired-source row as expired by construction", () => { + // ADR-0054: an `expired` row is only ever written when the conditioning + // certificate predates 2012, so it is >10 years old by definition. + expect(render(isExpiredSql(sql`e`))).toContain("epx.id IS NOT NULL"); + }); + + it("falls back to inspection_date when the lodged cert has no registration_date", () => { + // Reading registration_date alone silently counted date-less lodged certs as + // current; lodgementDateSql already coalesces the same way. + expect(render(isExpiredSql(sql`e`))).toContain( + "COALESCE(epl.registration_date, epl.inspection_date)", + ); + }); + + it("excludes gap-filled legacy rows — nothing to expire without a certificate", () => { + expect(render(isExpiredSql(sql`e`))).toContain( + "NOT COALESCE(e.estimated, false)", + ); + }); +}); + +describe("provenanceSignalSql", () => { + it("shows the Estimated badge for both flavours of the predicted slot", () => { + // Without epx here, an expired-conditioned home would render with no badge at + // all, as though its picture came off a real current certificate. + expect(render(provenanceSignalSql)).toContain( + "(epp.id IS NOT NULL OR epx.id IS NOT NULL) AND epl.id IS NULL", + ); + }); +}); diff --git a/src/lib/services/epcSources.test.ts b/src/lib/services/epcSources.test.ts index 1d85a6ab..5b859fd9 100644 --- a/src/lib/services/epcSources.test.ts +++ b/src/lib/services/epcSources.test.ts @@ -3,7 +3,10 @@ import { CONSTRUCTION_AGE_BANDS, PROPERTY_TYPE_LABELS, } from "./epcSources"; -import { PROPERTY_TYPE_OPTIONS } from "@/app/utils/propertyFilters"; +import { + PROPERTY_TYPE_OPTIONS, + YEAR_BUILT_OPTIONS, +} from "@/app/utils/propertyFilters"; // The reporting age-band bucketing in getCountByAgeBand (databaseFunctions.ts), // mirrored so the CONSTRUCTION_AGE_BANDS representative years stay anchored to @@ -57,12 +60,26 @@ describe("CONSTRUCTION_AGE_BANDS", () => { }); describe("PROPERTY_TYPE_LABELS", () => { - it("maps codes to the labels the property-type filter options use", () => { + it("maps every code to a label the property-type filter offers as an option", () => { const optionLabels = new Set(PROPERTY_TYPE_OPTIONS.map((o) => o.label)); for (const label of Object.values(PROPERTY_TYPE_LABELS)) { - // Park home has no filter option — it's rare and charts render it as-is. - if (label === "Park home") continue; expect(optionLabels).toContain(label); } }); }); + +describe("YEAR_BUILT_OPTIONS (Construction Age)", () => { + // Guards the band-native filter (ADR-0012) against label drift — a mismatched + // en-dash or reworded band would silently make the filter match nothing. + it("is exactly the RdSAP band labels in order, plus Unknown", () => { + const bandLabels = Object.values(CONSTRUCTION_AGE_BANDS).map((b) => b.label); + expect(YEAR_BUILT_OPTIONS.map((o) => o.label)).toEqual([ + ...bandLabels, + "Unknown", + ]); + }); + + it("uses each band label as its own dbValue", () => { + for (const o of YEAR_BUILT_OPTIONS) expect(o.dbValues).toEqual([o.label]); + }); +}); diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index b47ef438..5c47b471 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -70,11 +70,33 @@ type Alias = ReturnType; /** TRUE for new-approach rows. */ export const isNewApproachSql = sql`p.updated_at >= ${NEW_APPROACH_CUTOFF}::date`; -/** LEFT JOINs that expose the new sources alongside the legacy table. */ +/** + * LEFT JOINs that expose the new sources alongside the legacy table. + * + * `epc_property.source` has three values, not two. `lodged` is a real certificate. + * The other two share one slot — the *predicted slot* — and are both gap-fills: + * + * - `predicted` — synthesised blind from the postcode cohort. Nothing is known + * about this dwelling's own energy performance; there is no certificate for it + * anywhere. + * - `expired` — synthesised, but conditioned on the dwelling's own expired + * pre-2012 certificate from the historic register (Model ADR-0054). The gov EPC + * API only serves certificates registered since 2012, so these dwellings look + * EPC-less to ingestion even though a real, stale certificate exists. + * + * That distinction is the whole point: a home with an `expired` row **has** an EPC + * (an expired one); a home with only a `predicted` row has none at all. + * + * Matching `source = 'predicted'` alone would silently drop every `expired` row — + * it would match neither `epl` nor `epp`, and the home would read as though it had + * a valid current certificate. Address the *slot* (`epp` OR `epx`), never one member. + * Mirrors PREDICTED_SLOT_SOURCES in the Model repo. + */ export const newApproachJoins = sql` LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged' LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted' + LEFT JOIN epc_property epx ON epx.property_id = p.id AND epx.source = 'expired' `; /** @@ -133,7 +155,7 @@ export const lodgedSapSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN ep export const provenanceSignalSql = sql`CASE WHEN ${isNewApproachSql} THEN ( CASE - WHEN epp.id IS NOT NULL AND epl.id IS NULL THEN 'estimated' + WHEN (epp.id IS NOT NULL OR epx.id IS NOT NULL) AND epl.id IS NULL THEN 'estimated' WHEN epl.id IS NOT NULL AND bp.rebaseline_reason IS NOT NULL AND bp.rebaseline_reason <> 'none' THEN 'remodelled' ELSE 'none' END @@ -180,22 +202,87 @@ export const lodgementDateSql = (e: Alias) => sql`CASE WHEN ${isNewApproachSql} THEN COALESCE(epl.registration_date, epl.inspection_date) ELSE ${e}.lodgement_date END`; /** - * "estimated" boolean. New: the property only has a predicted EPC (no lodged - * row). Legacy: e.estimated. + * "estimated" — is this property's EPC picture a gap-fill rather than a real + * certificate? True for *either* flavour of the predicted slot: a blind + * `predicted` and a cert-conditioned `expired` are both synthesised, so both + * carry the Estimated provenance signal. + * + * This answers "was it modelled?", NOT "does a certificate exist?" — an `expired` + * property is estimated AND has an (expired) certificate. For the latter question + * use `withoutEpcSql`. Conflating the two is what made the reporting cards wrong. + * + * Legacy: e.estimated. */ export const estimatedSql = (e: Alias) => - sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND epp.id IS NOT NULL) ELSE COALESCE(${e}.estimated, false) END`; + sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND (epp.id IS NOT NULL OR epx.id IS NOT NULL)) ELSE COALESCE(${e}.estimated, false) END`; /** - * "expired" boolean. New: derived from the lodged EPC age (>10 years), since the - * new graph stores no is_expired flag. Legacy: e.is_expired. + * "no EPC at all" — we found neither a lodgement record nor a historical one, so + * nothing has ever been certified for this dwelling and its picture was gap-filled + * blind. This is the "Homes Without an EPC" card. + * + * Note a plain `predicted` row does not make a property "with" an EPC — a + * prediction is not a certificate. Only `lodged` (a current cert) or `expired` (a + * historical cert we conditioned on) count as having one. A property with no + * epc_property rows at all also lands here, which is right: it too has no EPC. + * + * Legacy: the flat `estimated` flag, which meant exactly this before the register + * had a historical tier to distinguish. + */ +export const withoutEpcSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND epx.id IS NULL) ELSE COALESCE(${e}.estimated, false) END`; + +/** + * "expired" — we found a certificate, of one kind or the other, and it is out of + * date. Two ways that happens: + * + * - an `expired` row exists: by construction (Model ADR-0054) it was conditioned + * on a pre-2012 certificate, which is >10 years old by definition; or + * - the lodged certificate is itself more than 10 years old. + * + * The lodged date falls back to `inspection_date` when `registration_date` is null — + * a sizeable minority of lodged rows carry only the former, and reading + * registration_date alone silently counted them as current. `lodgementDateSql` + * above already coalesces the same way. + * + * A blindly-`predicted` property is never expired: it has no certificate to expire. + * Legacy: e.is_expired, less the gap-filled rows (same reason). */ export const isExpiredSql = (e: Alias) => - sql`CASE WHEN ${isNewApproachSql} THEN (epl.registration_date IS NOT NULL AND epl.registration_date < (CURRENT_DATE - INTERVAL '10 years')) ELSE ${e}.is_expired END`; + sql`CASE WHEN ${isNewApproachSql} THEN ( + epx.id IS NOT NULL + OR COALESCE(epl.registration_date, epl.inspection_date) < (CURRENT_DATE - INTERVAL '10 years') + ) ELSE (${e}.is_expired AND NOT COALESCE(${e}.estimated, false)) END`; -/** Main fuel. GAP for new properties (no clean equivalent) → NULL. Legacy: e.mainfuel. */ +/** + * The main-fuel scalar for one epc_property alias, read from the JSONB + * `epc_main_heating_detail.main_fuel_type` as a correlated subquery. The stored + * scalar is either a numeric RdSAP `main_fuel` code ("26", "29", … — ~95% of + * rows, from the gov-EPC API) or an EPR text string ("Mains gas", "Bulk LPG", … + * from Elmhurst/PASHub). `#>> '{}'` extracts the JSON scalar as text; NULLIF + * drops the empty-string sentinel to NULL. Codes/strings are folded to a label + * downstream by MAINFUEL_OPTIONS — the fragment emits the raw value, like + * resolvedMainFuelSql's legacy branch. (No index on epc_property_id yet — see + * ix_epc_main_heating_detail_epc_property in the schema.) + */ +const epcMainFuelSql = (ep: Alias) => sql`( + SELECT NULLIF(mhd.main_fuel_type #>> '{}', '') + FROM epc_main_heating_detail mhd + WHERE mhd.epc_property_id = ${ep}.id + LIMIT 1 +)`; + +/** + * Main fuel raw value. New: the epc_main_heating_detail scalar (lodged over + * predicted) — an RdSAP code or an EPR string, folded to a label by + * MAINFUEL_OPTIONS. Legacy: e.mainfuel. Mirrors the new-approach branch of + * resolveMainFuel. + */ export const mainfuelSql = (e: Alias) => - sql`CASE WHEN ${isNewApproachSql} THEN NULL ELSE ${e}.mainfuel END`; + sql`CASE + WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${epcMainFuelSql(sql`epl`)} ELSE ${epcMainFuelSql(sql`epp`)} END) + ELSE ${e}.mainfuel + END`; // ───────────────────────────────────────────────────────────────────────────── // RdSAP code → label maps for the descriptive fields the new pipeline stores as @@ -273,6 +360,108 @@ export const builtFormTypeSql = sql`CASE ELSE p.built_form END`; +// ───────────────────────────────────────────────────────────────────────────── +// Landlord-override-resolved descriptors (ADR-0012). Each fragment wraps the +// EPC-derived expression above with the property_overrides snapshot and a +// never-null 'Unknown' terminal — the same precedence previewModellingRun +// applies (override → EPC-derived → Unknown). The TS twins in +// descriptorResolution.ts (resolvePropertyType, …) own and unit-test this +// precedence; keep the two in sync. +// +// A resolved fragment references an override alias that its join provides — add +// the matching *OverrideJoin to any query that selects/filters on the fragment. +// Overrides are whole-dwelling for these components, so the join pins +// building_part = 0 (the main building) and never multiplies rows. +// ───────────────────────────────────────────────────────────────────────────── + +/** LEFT JOIN exposing the property_type override snapshot (alias `pot`). */ +export const propertyTypeOverrideJoin = sql` + LEFT JOIN property_overrides pot + ON pot.property_id = p.id + AND pot.override_component = 'property_type' + AND pot.building_part = 0 +`; + +/** property_type: override → EPC-derived → 'Unknown'. Mirrors resolvePropertyType. */ +export const resolvedPropertyTypeSql = sql`COALESCE(pot.override_value, ${propertyTypeSql}, 'Unknown')`; + +/** LEFT JOIN exposing the built_form_type override snapshot (alias `pobf`). */ +export const builtFormOverrideJoin = sql` + LEFT JOIN property_overrides pobf + ON pobf.property_id = p.id + AND pobf.override_component = 'built_form_type' + AND pobf.building_part = 0 +`; + +/** built_form: override → EPC-derived → 'Unknown'. Mirrors resolveBuiltForm. */ +export const resolvedBuiltFormSql = sql`COALESCE(pobf.override_value, ${builtFormTypeSql}, 'Unknown')`; + +/** LEFT JOIN exposing the main_fuel override snapshot (alias `pmf`). */ +export const mainFuelOverrideJoin = sql` + LEFT JOIN property_overrides pmf + ON pmf.property_id = p.id + AND pmf.override_component = 'main_fuel' + AND pmf.building_part = 0 +`; + +/** + * main fuel (raw value; folded to a label by the cell's resolveEnumLabel): + * override → EPC-derived → 'Unknown'. The new EPC graph has no main fuel, so + * mainfuelSql is NULL for new-approach — the override is the only new-approach + * source. Takes the legacy `property_details_epc` alias. Mirrors resolveMainFuel. + */ +export const resolvedMainFuelSql = (e: Alias) => + sql`COALESCE(pmf.override_value, ${mainfuelSql(e)}, 'Unknown')`; + +// ─── Free-text component descriptors: wall / roof / heating (ADR-0012) ──────── +// override → EPC-derived (epc_energy_element description, lodged over predicted) +// → legacy property_details_epc column → 'Unknown'. Mirrors resolveOverrideDescriptor. + +/** One EPC source's description for an energy element, as a correlated subquery. */ +const epcElementDescSql = (ep: Alias, elementType: string) => sql`( + SELECT ee.description FROM epc_energy_element ee + WHERE ee.epc_property_id = ${ep}.id AND ee.element_type = ${elementType} + LIMIT 1 +)`; + +/** EPC-or-legacy element description: new = lodged over predicted; legacy = the property_details_epc column. */ +const epcElementSql = (elementType: string, legacyCol: Alias) => sql`CASE + WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${epcElementDescSql(sql`epl`, elementType)} ELSE ${epcElementDescSql(sql`epp`, elementType)} END) + ELSE ${legacyCol} +END`; + +/** LEFT JOIN exposing the wall_type override snapshot at the main building part (alias `pwall`). */ +export const wallTypeOverrideJoin = sql` + LEFT JOIN property_overrides pwall + ON pwall.property_id = p.id + AND pwall.override_component = 'wall_type' + AND pwall.building_part = 0 +`; +/** LEFT JOIN exposing the roof_type override snapshot at the main building part (alias `proof`). */ +export const roofTypeOverrideJoin = sql` + LEFT JOIN property_overrides proof + ON proof.property_id = p.id + AND proof.override_component = 'roof_type' + AND proof.building_part = 0 +`; +/** LEFT JOIN exposing the main_heating_system override snapshot (alias `pheat`). */ +export const mainHeatingOverrideJoin = sql` + LEFT JOIN property_overrides pheat + ON pheat.property_id = p.id + AND pheat.override_component = 'main_heating_system' + AND pheat.building_part = 0 +`; + +/** wall type: override → EPC 'wall' description → legacy walls → 'Unknown'. Mirrors resolveOverrideDescriptor. */ +export const resolvedWallTypeSql = (e: Alias) => + sql`COALESCE(pwall.override_value, ${epcElementSql("wall", sql`${e}.walls`)}, 'Unknown')`; +/** roof type: override → EPC 'roof' description → legacy roof → 'Unknown'. */ +export const resolvedRoofTypeSql = (e: Alias) => + sql`COALESCE(proof.override_value, ${epcElementSql("roof", sql`${e}.roof`)}, 'Unknown')`; +/** heating system: override → EPC 'main_heating' description → legacy heating → 'Unknown'. */ +export const resolvedHeatingSystemSql = (e: Alias) => + sql`COALESCE(pheat.override_value, ${epcElementSql("main_heating", sql`${e}.heating`)}, 'Unknown')`; + /** * The main building part's construction age band for one epc_property alias, as * a correlated scalar subquery — properties can also have extension parts, so a @@ -306,6 +495,52 @@ export const constructionYearSql = sql`CASE ELSE NULL END`; +// ─── Construction age as a band LABEL (ADR-0012) ───────────────────────────── +// Unlike constructionYearSql (which flattens the band to a representative year +// for reporting buckets), the portfolio list keeps the band itself. Mirrors +// resolveConstructionAge in descriptorResolution.ts — keep in sync. + +/** RdSAP band letter → band label (e.g. B → "1900–1929"). */ +const AGE_BAND_LABELS: Record = Object.fromEntries( + Object.entries(CONSTRUCTION_AGE_BANDS).map(([letter, { label }]) => [letter, label]), +); + +/** Bucket a legacy year_built expression into its band label; non-numeric → NULL. */ +const legacyYearBandLabelSql = (yearExpr: Alias) => { + const y = sql`CAST(${yearExpr} AS int)`; + const thresholds = Object.entries(CONSTRUCTION_AGE_BANDS) + .filter(([letter]) => letter !== "A") + .sort((a, b) => b[1].year - a[1].year) + .map(([, { year, label }]) => sql`WHEN ${y} >= ${year} THEN ${label}`); + // The numeric guard fires the CAST only on digit strings; below the earliest + // band start falls through to band A. + return sql`CASE WHEN ${yearExpr} ~ '^[0-9]+$' THEN (CASE ${sql.join( + thresholds, + sql` `, + )} ELSE ${CONSTRUCTION_AGE_BANDS.A.label} END) ELSE NULL END`; +}; + +/** EPC-or-legacy band label: new-approach main-part band (lodged over predicted); legacy year bucketed. */ +const constructionAgeBandLabelSql = sql`CASE + WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${codeLabelCaseSql(mainPartAgeBandSql(sql`epl`), AGE_BAND_LABELS)} ELSE ${codeLabelCaseSql(mainPartAgeBandSql(sql`epp`), AGE_BAND_LABELS)} END) + ELSE ${legacyYearBandLabelSql(sql`p.year_built`)} +END`; + +/** LEFT JOIN exposing the construction_age_band override snapshot (alias `pab`). */ +export const constructionAgeOverrideJoin = sql` + LEFT JOIN property_overrides pab + ON pab.property_id = p.id + AND pab.override_component = 'construction_age_band' + AND pab.building_part = 0 +`; + +/** construction age: override → EPC band → legacy year bucket → 'Unknown'. Mirrors resolveConstructionAge. */ +export const resolvedConstructionAgeSql = sql`COALESCE( + ${codeLabelCaseSql(sql`pab.override_value`, AGE_BAND_LABELS)}, + ${constructionAgeBandLabelSql}, + 'Unknown' +)`; + // ───────────────────────────────────────────────────────────────────────────── // Per-property resolvers (TypeScript object building). // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/lib/tags/bulkAssign.test.ts b/src/lib/tags/bulkAssign.test.ts new file mode 100644 index 00000000..a93c1a1a --- /dev/null +++ b/src/lib/tags/bulkAssign.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { parseTagIdentifierRows, MAX_BULK_TAG_ROWS } from "./bulkAssign"; + +describe("parseTagIdentifierRows", () => { + it("extracts landlord property ids from a landlord_property_id column", () => { + const rows = [["landlord_property_id"], ["ABC-001"], ["ABC-002"]]; + expect(parseTagIdentifierRows(rows)).toEqual({ + ok: true, + key: "landlord_property_id", + identifiers: ["ABC-001", "ABC-002"], + }); + }); + + it("detects a UPRN column (header normalised case-insensitively)", () => { + const rows = [["UPRN"], [100023336956], [100023336957]]; + expect(parseTagIdentifierRows(rows)).toEqual({ + ok: true, + key: "uprn", + identifiers: ["100023336956", "100023336957"], + }); + }); + + it("normalises a spaced/cased header and trims + dedupes + drops blank ids", () => { + const rows = [ + ["Landlord Property ID"], + [" ABC-001 "], + ["ABC-001"], + [""], + ["ABC-002"], + ]; + expect(parseTagIdentifierRows(rows)).toEqual({ + ok: true, + key: "landlord_property_id", + identifiers: ["ABC-001", "ABC-002"], + }); + }); + + it("errors when neither identifier column is present", () => { + const rows = [["address", "postcode"], ["12 Oak St", "SW1"]]; + expect(parseTagIdentifierRows(rows)).toEqual({ + ok: false, + error: + "The file needs a 'landlord_property_id' or 'uprn' column in the first row", + }); + }); + + it("errors on an empty file (no data rows)", () => { + expect(parseTagIdentifierRows([["uprn"]])).toEqual({ + ok: false, + error: "The file has no rows to assign", + }); + }); + + it("errors when the file exceeds the row cap", () => { + const rows: (string | number)[][] = [["uprn"]]; + for (let i = 0; i < MAX_BULK_TAG_ROWS + 1; i++) rows.push([`1000${i}`]); + const result = parseTagIdentifierRows(rows); + expect(result.ok).toBe(false); + expect(result.ok === false && result.error).toContain( + String(MAX_BULK_TAG_ROWS), + ); + }); +}); diff --git a/src/lib/tags/bulkAssign.ts b/src/lib/tags/bulkAssign.ts new file mode 100644 index 00000000..10cd1cb0 --- /dev/null +++ b/src/lib/tags/bulkAssign.ts @@ -0,0 +1,61 @@ +/** + * Pure parsing for Bulk tag assignment (ADR-0013). The client reads the uploaded + * CSV/Excel into a 2D array of rows (header row first, via SheetJS) and this + * turns it into the identifier column to match on — either `landlord_property_id` + * or `uprn`, whichever header the file carries. Kept DB-free so it unit-tests and + * runs in the browser before anything is sent to the server. + */ + +/** Upper bound on rows per upload ("relatively small file", ADR-0013). */ +export const MAX_BULK_TAG_ROWS = 10_000; + +export type IdentifierKey = "landlord_property_id" | "uprn"; + +export type ParsedIdentifiers = + | { ok: true; key: IdentifierKey; identifiers: string[] } + | { ok: false; error: string }; + +type Cell = string | number | null | undefined; + +/** Normalise a header cell: trimmed, lowercased, spaces/hyphens → underscores. */ +function normaliseHeader(cell: Cell): string { + return String(cell ?? "") + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); +} + +export function parseTagIdentifierRows(rows: Cell[][]): ParsedIdentifiers { + const [header, ...dataRows] = rows; + const headers = (header ?? []).map(normaliseHeader); + + const keyIndex = headers.findIndex( + (h) => h === "landlord_property_id" || h === "uprn", + ); + if (keyIndex === -1) { + return { + ok: false, + error: + "The file needs a 'landlord_property_id' or 'uprn' column in the first row", + }; + } + const key = headers[keyIndex] as IdentifierKey; + + const seen = new Set(); + for (const row of dataRows) { + const value = String(row[keyIndex] ?? "").trim(); + if (value.length > 0) seen.add(value); + } + + if (seen.size === 0) { + return { ok: false, error: "The file has no rows to assign" }; + } + if (seen.size > MAX_BULK_TAG_ROWS) { + return { + ok: false, + error: `The file has more than ${MAX_BULK_TAG_ROWS} properties — split it into smaller files`, + }; + } + + return { ok: true, key, identifiers: [...seen] }; +} diff --git a/src/lib/tags/colour.test.ts b/src/lib/tags/colour.test.ts new file mode 100644 index 00000000..90d5524d --- /dev/null +++ b/src/lib/tags/colour.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + normaliseHex, + tagDotColour, + tagChipTokens, + luminance, +} from "./colour"; + +/** WCAG relative-luminance + contrast for asserting chip legibility in tests. */ +function lum(hex: string): number { + const n = hex.replace("#", ""); + const [r, g, b] = [0, 2, 4].map((i) => { + const s = parseInt(n.slice(i, i + 2), 16) / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +function contrast(a: string, b: string): number { + const [la, lb] = [lum(a), lum(b)]; + const [hi, lo] = la >= lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} + +describe("normaliseHex", () => { + it("lowercases, hashes, and expands 3-digit hex", () => { + expect(normaliseHex(" #FDE047 ")).toBe("#fde047"); + expect(normaliseHex("#FFF")).toBe("#ffffff"); + }); + it("returns null for an unparseable colour", () => { + expect(normaliseHex("not-a-colour")).toBeNull(); + }); +}); + +describe("tagDotColour", () => { + it("returns the normalised hex", () => { + expect(tagDotColour("#7C3AED")).toBe("#7c3aed"); + }); + it("falls back to slate for garbage input", () => { + expect(tagDotColour("nope")).toBe("#64748b"); + }); +}); + +describe("luminance", () => { + it("ranks black below white", () => { + expect(luminance("#000000")).toBeLessThan(luminance("#ffffff")); + }); +}); + +describe("tagChipTokens", () => { + it("builds a soft tint, border, dot, and readable ink", () => { + const t = tagChipTokens("#7c3aed"); + expect(t.dot).toBe("#7c3aed"); + // Both tint and border are blended over white, so lighter than the source; + // the border carries more of the colour, so it sits between tint and source. + expect(luminance(t.backgroundColor)).toBeGreaterThan(luminance("#7c3aed")); + expect(luminance(t.borderColor)).toBeGreaterThan(luminance("#7c3aed")); + expect(luminance(t.borderColor)).toBeLessThan(luminance(t.backgroundColor)); + }); + + it("guarantees AA (≥4.5:1) ink for a hard, pale colour (yellow)", () => { + const t = tagChipTokens("#fde047"); + expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5); + }); + + it("guarantees AA ink across a spread of freeform hues", () => { + for (const c of ["#ef4444", "#22c55e", "#3b82f6", "#000000", "#ffffff", "#14163d"]) { + const t = tagChipTokens(c); + expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5); + } + }); + + it("keeps a dark tag's ink close to its own hue (minimal darkening)", () => { + // Navy is already dark enough — ink should not collapse to pure black. + expect(tagChipTokens("#14163d").color).not.toBe("#000000"); + }); + + it("falls back to a neutral chip for an unparseable colour", () => { + const t = tagChipTokens("garbage"); + expect(t.dot).toBe("#64748b"); + expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5); + }); +}); diff --git a/src/lib/tags/colour.ts b/src/lib/tags/colour.ts new file mode 100644 index 00000000..88986148 --- /dev/null +++ b/src/lib/tags/colour.ts @@ -0,0 +1,113 @@ +/** + * Pure colour helpers for rendering Tag chips (ADR-0013). A Tag's colour is a + * freeform hex, so chips must stay calm and legible for ANY hue — the product is + * institutional and WCAG-AA (PRODUCT.md). The chip vocabulary is one system + * everywhere: a soft tint of the colour, a colour dot, and hue-preserving dark + * ink whose contrast against the tint is guaranteed ≥ 4.5:1. Kept DB-free + + * side-effect-free so it unit-tests cleanly and is shared by every chip surface. + */ + +type Rgb = [number, number, number]; + +/** Slate-500 — a neutral fallback for an unparseable colour. */ +const FALLBACK = "#64748b"; +/** Minimum WCAG contrast for the chip's ink against its tint background. */ +const MIN_CONTRAST = 4.5; + +/** Parse `#rgb` / `#rrggbb` (any case, surrounding space) → [r,g,b] 0-255, or null. */ +function parseHex(input: string): Rgb | null { + const hex = input.trim().toLowerCase().replace(/^#/, ""); + const full = + hex.length === 3 + ? hex.split("").map((c) => c + c).join("") + : hex; + if (!/^[0-9a-f]{6}$/.test(full)) return null; + return [ + parseInt(full.slice(0, 2), 16), + parseInt(full.slice(2, 4), 16), + parseInt(full.slice(4, 6), 16), + ]; +} + +function toHex(rgb: Rgb): string { + return "#" + rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join(""); +} + +/** Normalise a hex to lowercase `#rrggbb`, or null if unparseable. */ +export function normaliseHex(input: string): string | null { + const rgb = parseHex(input); + return rgb ? toHex(rgb) : null; +} + +/** The dot colour for a Tag — its normalised hex, or a neutral fallback. */ +export function tagDotColour(colour: string): string { + return normaliseHex(colour) ?? FALLBACK; +} + +function luminanceRgb([r, g, b]: Rgb): number { + const lin = [r, g, b].map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; +} + +/** Relative luminance (WCAG) of a hex colour, 0 → 1; unparseable → 1 (light). */ +export function luminance(input: string): number { + const rgb = parseHex(input); + return rgb ? luminanceRgb(rgb) : 1; +} + +function contrast(a: Rgb, b: Rgb): number { + const la = luminanceRgb(a); + const lb = luminanceRgb(b); + const [hi, lo] = la >= lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} + +/** Blend `colour` over white at `alpha` (0-1) → the resulting opaque rgb. */ +function overWhite(rgb: Rgb, alpha: number): Rgb { + return rgb.map((c) => c * alpha + 255 * (1 - alpha)) as Rgb; +} + +/** Mix `rgb` toward black by `t` (0 = unchanged, 1 = black). */ +function towardBlack(rgb: Rgb, t: number): Rgb { + return rgb.map((c) => c * (1 - t)) as Rgb; +} + +/** + * A dark, hue-preserving ink for `colour` that reads at ≥ 4.5:1 on `bg`. Starts + * from the colour itself (dark tags keep their exact hue) and darkens toward + * black only as far as legibility requires. + */ +function readableInk(rgb: Rgb, bg: Rgb): string { + for (let t = 0; t <= 1.0001; t += 0.05) { + const candidate = towardBlack(rgb, t); + if (contrast(candidate, bg) >= MIN_CONTRAST) return toHex(candidate); + } + return "#000000"; +} + +export interface ChipTokens { + backgroundColor: string; + borderColor: string; + color: string; + dot: string; +} + +/** + * Inline tokens for a soft Tag chip: a light tint of the colour, a slightly + * stronger border, a colour dot, and dark hue-preserving ink guaranteed legible + * on the tint. One quiet, AA-safe vocabulary for every chip in the app. + */ +export function tagChipTokens(colour: string): ChipTokens { + const rgb = parseHex(colour) ?? parseHex(FALLBACK)!; + const tint = overWhite(rgb, 0.14); + const border = overWhite(rgb, 0.34); + return { + backgroundColor: toHex(tint), + borderColor: toHex(border), + color: readableInk(rgb, tint), + dot: toHex(rgb), + }; +} diff --git a/src/lib/tags/model.test.ts b/src/lib/tags/model.test.ts new file mode 100644 index 00000000..1b012fc0 --- /dev/null +++ b/src/lib/tags/model.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { validateTagDraft } from "./model"; + +describe("validateTagDraft", () => { + it("accepts a valid name + hex colour, trimming the name and lowercasing the hex", () => { + expect(validateTagDraft({ name: " Void ", colour: "#7C3AED" }, [])).toEqual({ + ok: true, + tag: { name: "Void", colour: "#7c3aed" }, + }); + }); + + it("rejects a blank name (empty or whitespace-only)", () => { + expect(validateTagDraft({ name: " ", colour: "#7c3aed" }, [])).toEqual({ + ok: false, + error: "Tag name is required", + }); + }); + + it("rejects a name longer than 30 characters (after trimming)", () => { + const long = "a".repeat(31); + expect(validateTagDraft({ name: long, colour: "#7c3aed" }, [])).toEqual({ + ok: false, + error: "Tag name must be 30 characters or fewer", + }); + }); + + it("accepts a name of exactly 30 characters (boundary)", () => { + const name = "a".repeat(30); + expect(validateTagDraft({ name, colour: "#7c3aed" }, [])).toEqual({ + ok: true, + tag: { name, colour: "#7c3aed" }, + }); + }); + + it("rejects a name that duplicates an existing one, case-insensitively", () => { + expect( + validateTagDraft({ name: " VOID ", colour: "#7c3aed" }, ["Void", "Phase 1"]), + ).toEqual({ ok: false, error: "A tag with this name already exists" }); + }); + + it("accepts a name that clashes with no existing tag (edit excludes its own name)", () => { + // On edit the caller passes the OTHER tags' names, so keeping/​recasing the + // tag's own name is allowed. + expect( + validateTagDraft({ name: "Void", colour: "#7c3aed" }, ["Phase 1"]), + ).toEqual({ ok: true, tag: { name: "Void", colour: "#7c3aed" } }); + }); + + it("rejects a colour that is not a hex code", () => { + expect(validateTagDraft({ name: "Void", colour: "red" }, [])).toEqual({ + ok: false, + error: "Colour must be a hex code like #7c3aed", + }); + }); + + it("accepts a 3-digit hex shorthand, normalised to lowercase", () => { + expect(validateTagDraft({ name: "Void", colour: "#ABC" }, [])).toEqual({ + ok: true, + tag: { name: "Void", colour: "#abc" }, + }); + }); +}); diff --git a/src/lib/tags/model.ts b/src/lib/tags/model.ts new file mode 100644 index 00000000..49e8f39e --- /dev/null +++ b/src/lib/tags/model.ts @@ -0,0 +1,43 @@ +/** + * Pure Tag domain logic — validation + normalisation for creating/editing a + * portfolio Tag (ADR-0013). Kept free of DB access so it unit-tests cleanly and + * is shared by the tag routes. The DB's case-insensitive unique index is the + * ultimate guard against duplicate names; this gives a friendly pre-check. + */ + +export const MAX_TAG_NAME_LENGTH = 30; + +/** A hex colour: #rgb or #rrggbb (case-insensitive). */ +const HEX_COLOUR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; + +export interface TagDraft { + name: string; + colour: string; +} + +export type TagValidation = + | { ok: true; tag: TagDraft } + | { ok: false; error: string }; + +export function validateTagDraft( + input: TagDraft, + existingNames: string[], +): TagValidation { + const name = input.name.trim(); + if (name.length === 0) return { ok: false, error: "Tag name is required" }; + if (name.length > MAX_TAG_NAME_LENGTH) { + return { + ok: false, + error: `Tag name must be ${MAX_TAG_NAME_LENGTH} characters or fewer`, + }; + } + const lower = name.toLowerCase(); + if (existingNames.some((existing) => existing.trim().toLowerCase() === lower)) { + return { ok: false, error: "A tag with this name already exists" }; + } + const colour = input.colour.trim().toLowerCase(); + if (!HEX_COLOUR.test(colour)) { + return { ok: false, error: "Colour must be a hex code like #7c3aed" }; + } + return { ok: true, tag: { name, colour } }; +} diff --git a/src/lib/tags/server.ts b/src/lib/tags/server.ts new file mode 100644 index 00000000..c4b00183 --- /dev/null +++ b/src/lib/tags/server.ts @@ -0,0 +1,48 @@ +/** + * Server-side helpers shared by the tag routes: portfolio-role lookup and the + * write gate. Tags are portfolio-scoped; per ADR-0013 (Q11) any member with a + * write-capable role manages and assigns them, read members are view-only. + */ +import { db } from "@/app/db/db"; +import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { and, eq } from "drizzle-orm"; + +/** Roles allowed to create/edit/delete/assign tags (read is view-only). */ +const WRITE_ROLES = new Set(["creator", "admin", "write"]); + +export async function getPortfolioRole( + portfolioId: bigint, + email: string, +): Promise { + const rows = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .innerJoin(user, eq(user.id, portfolioUsers.userId)) + .where( + and(eq(portfolioUsers.portfolioId, portfolioId), eq(user.email, email)), + ) + .limit(1); + return rows[0]?.role ?? null; +} + +/** A member (any role) may read; only write-capable roles may mutate. */ +export function canReadTags(role: string | null): boolean { + return role != null; +} + +export function canWriteTags(role: string | null): boolean { + return role != null && WRITE_ROLES.has(role); +} + +/** + * True when `err` is a Postgres unique-violation (SQLSTATE 23505) — the + * case-insensitive tag-name index racing a concurrent create/rename. node-postgres + * surfaces the code on the thrown error (or its `.cause`). Everything else is a + * real failure the caller must not mask as a duplicate-name 409. + */ +export function isUniqueViolation(err: unknown): boolean { + const code = (err as { code?: unknown; cause?: { code?: unknown } })?.code + ?? (err as { cause?: { code?: unknown } })?.cause?.code; + return code === "23505"; +}