From 54cc71366ba35619b2fdb28a198cf0fc2b461e6b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 6 Jul 2026 16:08:33 +0000 Subject: [PATCH] feat(landlord-overrides): wire all 9 override categories through the app layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DB schema, value enums, and migrations already defined all nine landlord override categories, but the application layer was hardwired to the original four (property_type, built_form_type, wall_type, roof_type). The other five (main_fuel, glazing, construction_age_band, water_heating, main_heating_system) were classified and written to their tables by the backend, but the frontend never read them or offered edit dropdowns — so the verify panel rendered them as "not classified", and any Unknown in those categories slipped past the ADR-0006 finalise gate. Replace the per-category switch statements (duplicated across ~6 sites) with a single CATEGORY_TABLES registry (categories.ts) as the source of truth. Adding a category is now one registry row. A completeness test asserts the registry, CLASSIFIER_FIELDS, and the property_overrides override_component enum never drift apart — the exact hole that left five categories unwired. - categories.ts: registry + derived CATEGORY_VALUES + isLandlordCategory guard - landlordOverrides/server.ts: getLandlordOverrides reads all 9 (fixes the latent crash where the page mapped 9 fields against a 4-key result) - bulkUpload/server.ts: lookupOverrides / unknownForField / getUnknownOverrides / upsertUserOverride / validation now registry-driven, all 9 - OnboardingProgress.tsx: edit dropdowns use the shared 9-category CATEGORY_VALUES - landlord-overrides page: SourceBadge now renders os_places (ADR-0007) distinctly instead of mislabelling it "classifier" Enum value sets are already in sync FE<->Model (verified); only member order drifts on 3 enums, which is cosmetic and left untouched (Postgres cannot reorder enums). Follow-up (backend): Model's override_source SAEnum still lacks os_places. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[uploadId]/OnboardingProgress.tsx | 22 +-- .../(portfolio)/landlord-overrides/page.tsx | 20 ++- src/lib/bulkUpload/server.ts | 158 +++++------------- src/lib/landlordOverrides/categories.test.ts | 52 ++++++ src/lib/landlordOverrides/categories.ts | 98 +++++++++++ src/lib/landlordOverrides/server.ts | 96 ++++------- 6 files changed, 248 insertions(+), 198 deletions(-) create mode 100644 src/lib/landlordOverrides/categories.test.ts create mode 100644 src/lib/landlordOverrides/categories.ts 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 f5dc7cd0..586b1b1b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -21,23 +21,10 @@ import { type MultiEntrySample, } from "@/lib/bulkUpload/multiEntry"; import type { MultiEntryOrdering } from "@/app/db/schema/bulk_address_uploads"; -import { - PropertyTypeValues, - BuiltFormTypeValues, - WallTypeValues, - RoofTypeValues, -} from "@/app/db/schema/landlord_overrides"; +import { CATEGORY_VALUES } from "@/lib/landlordOverrides/categories"; import { CLASSIFIER_FIELDS } from "@/lib/bulkUpload/columnFields"; import { statusLabel, isTerminalStatus } from "@/lib/bulkUpload/types"; -// Valid enum options per classifier category, for the editable dropdowns (#299). -const CATEGORY_VALUES: Record = { - property_type: PropertyTypeValues, - built_form_type: BuiltFormTypeValues, - wall_type: WallTypeValues, - roof_type: RoofTypeValues, -}; - // Our category label per classifier field (e.g. built_form_type → "Built Form"). // Distinguishes the categories when several read from the same source column — // Property Type and Built Form both come from one column, so labelling by the @@ -362,7 +349,8 @@ function VerifyClassificationPanel({ seen.add(entry.description); return true; }); - const options = CATEGORY_VALUES[column.field] ?? []; + const options = + CATEGORY_VALUES[column.field as keyof typeof CATEGORY_VALUES] ?? []; return (

@@ -634,7 +622,9 @@ function UnresolvedClassificationsPanel({

{Object.entries(unknown).map(([field, descriptions]) => { - const options = (CATEGORY_VALUES[field] ?? []).filter((o) => o !== "Unknown"); + const options = ( + CATEGORY_VALUES[field as keyof typeof CATEGORY_VALUES] ?? [] + ).filter((o) => o !== "Unknown"); return (

diff --git a/src/app/portfolio/[slug]/(portfolio)/landlord-overrides/page.tsx b/src/app/portfolio/[slug]/(portfolio)/landlord-overrides/page.tsx index 9e9ac249..9907d24e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/landlord-overrides/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/landlord-overrides/page.tsx @@ -87,15 +87,25 @@ function OverrideSection({ title, rows }: { title: string; rows: OverrideRow[] } ); } +// Provenance of a resolved override value. os_places is the deterministic OS +// Places classification-code mapping (ADR-0007); user is a landlord correction; +// classifier is the ML default. +const SOURCE_BADGES: Record = { + user: { label: "user", className: "bg-emerald-50 text-emerald-700" }, + classifier: { label: "classifier", className: "bg-indigo-50 text-indigo-700" }, + os_places: { label: "OS Places", className: "bg-sky-50 text-sky-700" }, +}; + function SourceBadge({ source }: { source: string }) { - const isUser = source === "user"; + const badge = SOURCE_BADGES[source] ?? { + label: source, + className: "bg-gray-100 text-gray-600", + }; return ( - {isUser ? "user" : "classifier"} + {badge.label} ); } diff --git a/src/lib/bulkUpload/server.ts b/src/lib/bulkUpload/server.ts index ca303f9b..b442e0c3 100644 --- a/src/lib/bulkUpload/server.ts +++ b/src/lib/bulkUpload/server.ts @@ -1,15 +1,11 @@ import { db } from "@/app/db/db"; import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; import { - landlordPropertyTypeOverrides, - landlordBuiltFormTypeOverrides, - landlordWallTypeOverrides, - landlordRoofTypeOverrides, - PropertyTypeValues, - BuiltFormTypeValues, - WallTypeValues, - RoofTypeValues, -} from "@/app/db/schema/landlord_overrides"; + CATEGORY_TABLES, + CATEGORY_VALUES, + LANDLORD_CATEGORIES, + isLandlordCategory, +} from "@/lib/landlordOverrides/categories"; import { tasks } from "@/app/db/schema/tasks/tasks"; import { subTasks } from "@/app/db/schema/tasks/subtask"; import { and, count, desc, eq, inArray, sql } from "drizzle-orm"; @@ -135,37 +131,22 @@ export async function saveMultiEntrySummary( // classified (yet)". Read-only (ADR-0004, issue #298). export type SampleClassifications = Record>; -// Look up the classifier's resolved enums for one field's descriptions. One -// branch per category keeps drizzle's per-table enum typing intact. +// Look up the classifier's resolved enums for one field's descriptions, via the +// category registry (all nine landlord categories). Returns null for a field +// that is not a landlord category. async function lookupOverrides( field: string, portfolioId: bigint, descriptions: string[], ): Promise | null> { - switch (field) { - case "property_type": - return db - .select({ description: landlordPropertyTypeOverrides.description, value: landlordPropertyTypeOverrides.value }) - .from(landlordPropertyTypeOverrides) - .where(and(eq(landlordPropertyTypeOverrides.portfolioId, portfolioId), inArray(landlordPropertyTypeOverrides.description, descriptions))); - case "built_form_type": - return db - .select({ description: landlordBuiltFormTypeOverrides.description, value: landlordBuiltFormTypeOverrides.value }) - .from(landlordBuiltFormTypeOverrides) - .where(and(eq(landlordBuiltFormTypeOverrides.portfolioId, portfolioId), inArray(landlordBuiltFormTypeOverrides.description, descriptions))); - case "wall_type": - return db - .select({ description: landlordWallTypeOverrides.description, value: landlordWallTypeOverrides.value }) - .from(landlordWallTypeOverrides) - .where(and(eq(landlordWallTypeOverrides.portfolioId, portfolioId), inArray(landlordWallTypeOverrides.description, descriptions))); - case "roof_type": - return db - .select({ description: landlordRoofTypeOverrides.description, value: landlordRoofTypeOverrides.value }) - .from(landlordRoofTypeOverrides) - .where(and(eq(landlordRoofTypeOverrides.portfolioId, portfolioId), inArray(landlordRoofTypeOverrides.description, descriptions))); - default: - return null; - } + if (!isLandlordCategory(field)) return null; + const { table } = CATEGORY_TABLES[field]; + return db + .select({ description: table.description, value: table.value }) + .from(table) + .where( + and(eq(table.portfolioId, portfolioId), inArray(table.description, descriptions)), + ) as Promise>; } // The classifier's enums for the review samples' entries, joined by the @@ -213,103 +194,47 @@ export type UnknownOverrides = Record; const UNKNOWN_VALUE = "Unknown"; async function unknownForField(field: string, portfolioId: bigint): Promise { - switch (field) { - case "property_type": - return ( - await db - .select({ description: landlordPropertyTypeOverrides.description }) - .from(landlordPropertyTypeOverrides) - .where(and(eq(landlordPropertyTypeOverrides.portfolioId, portfolioId), eq(landlordPropertyTypeOverrides.value, UNKNOWN_VALUE))) - ).map((r) => r.description); - case "built_form_type": - return ( - await db - .select({ description: landlordBuiltFormTypeOverrides.description }) - .from(landlordBuiltFormTypeOverrides) - .where(and(eq(landlordBuiltFormTypeOverrides.portfolioId, portfolioId), eq(landlordBuiltFormTypeOverrides.value, UNKNOWN_VALUE))) - ).map((r) => r.description); - case "wall_type": - return ( - await db - .select({ description: landlordWallTypeOverrides.description }) - .from(landlordWallTypeOverrides) - .where(and(eq(landlordWallTypeOverrides.portfolioId, portfolioId), eq(landlordWallTypeOverrides.value, UNKNOWN_VALUE))) - ).map((r) => r.description); - case "roof_type": - return ( - await db - .select({ description: landlordRoofTypeOverrides.description }) - .from(landlordRoofTypeOverrides) - .where(and(eq(landlordRoofTypeOverrides.portfolioId, portfolioId), eq(landlordRoofTypeOverrides.value, UNKNOWN_VALUE))) - ).map((r) => r.description); - default: - return []; - } + if (!isLandlordCategory(field)) return []; + const { table } = CATEGORY_TABLES[field]; + const rows = (await db + .select({ description: table.description }) + .from(table) + .where( + and(eq(table.portfolioId, portfolioId), eq(table.value, UNKNOWN_VALUE)), + )) as Array<{ description: string }>; + return rows.map((r) => r.description); } export async function getUnknownOverrides(portfolioId: string): Promise { const pid = BigInt(portfolioId); const result: UnknownOverrides = {}; - for (const field of ["property_type", "built_form_type", "wall_type", "roof_type"]) { + for (const field of LANDLORD_CATEGORIES) { const descriptions = await unknownForField(field, pid); if (descriptions.length > 0) result[field] = descriptions; } return result; } -// Valid enum values per classifier category, for validating a user edit. -const CATEGORY_VALUES: Record = { - property_type: PropertyTypeValues, - built_form_type: BuiltFormTypeValues, - wall_type: WallTypeValues, - roof_type: RoofTypeValues, -}; - -// Upsert one user override (source='user') into the right category table. One -// branch per table keeps drizzle's per-table typing intact; the unique -// (portfolio_id, description) drives the conflict. Sets source='user' so the -// classifier's `ON CONFLICT … WHERE source='classifier'` never re-clobbers it. +// Upsert one user override (source='user') into the right category table via the +// registry. The unique (portfolio_id, description) drives the conflict. Sets +// source='user' so the classifier's `ON CONFLICT … WHERE source='classifier'` +// never re-clobbers it. No-op for a field that is not a landlord category +// (setClassificationOverride validates the field before calling). async function upsertUserOverride( field: string, portfolioId: bigint, description: string, value: string, ): Promise { - const now = new Date(); - switch (field) { - case "property_type": - await db.insert(landlordPropertyTypeOverrides) - .values({ portfolioId, description, value, source: "user" }) - .onConflictDoUpdate({ - target: [landlordPropertyTypeOverrides.portfolioId, landlordPropertyTypeOverrides.description], - set: { value, source: "user", updatedAt: now }, - }); - return; - case "built_form_type": - await db.insert(landlordBuiltFormTypeOverrides) - .values({ portfolioId, description, value, source: "user" }) - .onConflictDoUpdate({ - target: [landlordBuiltFormTypeOverrides.portfolioId, landlordBuiltFormTypeOverrides.description], - set: { value, source: "user", updatedAt: now }, - }); - return; - case "wall_type": - await db.insert(landlordWallTypeOverrides) - .values({ portfolioId, description, value, source: "user" }) - .onConflictDoUpdate({ - target: [landlordWallTypeOverrides.portfolioId, landlordWallTypeOverrides.description], - set: { value, source: "user", updatedAt: now }, - }); - return; - case "roof_type": - await db.insert(landlordRoofTypeOverrides) - .values({ portfolioId, description, value, source: "user" }) - .onConflictDoUpdate({ - target: [landlordRoofTypeOverrides.portfolioId, landlordRoofTypeOverrides.description], - set: { value, source: "user", updatedAt: now }, - }); - return; - } + if (!isLandlordCategory(field)) return; + const { table } = CATEGORY_TABLES[field]; + await db + .insert(table) + .values({ portfolioId, description, value, source: "user" }) + .onConflictDoUpdate({ + target: [table.portfolioId, table.description], + set: { value, source: "user", updatedAt: new Date() }, + }); } export type SetClassificationOutcome = @@ -326,8 +251,9 @@ export async function setClassificationOverride( description: string, value: string, ): Promise { + if (!isLandlordCategory(field)) + return { kind: "invalid", reason: `Unknown category '${field}'` }; const allowed = CATEGORY_VALUES[field]; - if (!allowed) return { kind: "invalid", reason: `Unknown category '${field}'` }; if (!allowed.includes(value)) return { kind: "invalid", reason: `'${value}' is not a valid ${field}` }; diff --git a/src/lib/landlordOverrides/categories.test.ts b/src/lib/landlordOverrides/categories.test.ts new file mode 100644 index 00000000..5d1b51f4 --- /dev/null +++ b/src/lib/landlordOverrides/categories.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + CATEGORY_TABLES, + CATEGORY_VALUES, + LANDLORD_CATEGORIES, + isLandlordCategory, +} from "./categories"; +import { CLASSIFIER_FIELD_VALUES } from "@/lib/bulkUpload/columnFields"; +import { OverrideComponentValues } from "@/app/db/schema/property_overrides"; + +// These tests guard the exact failure that left five categories rendering +// "not classified": a category defined in one place (schema table, mapping +// field, override component) but missing from the app-layer registry. If any of +// these sets drift apart, wiring is incomplete somewhere — fail loudly here +// rather than silently in the UI. +describe("landlord override category registry", () => { + const expected = [...LANDLORD_CATEGORIES].sort(); + + it("registry, value map, and table map cover the identical category set", () => { + expect(Object.keys(CATEGORY_TABLES).sort()).toEqual(expected); + expect(Object.keys(CATEGORY_VALUES).sort()).toEqual(expected); + }); + + it("covers exactly the classifier mapping fields", () => { + // Every classifier column the user can map must be wired, and vice versa — + // otherwise a mapped column classifies into a table nothing reads. + expect([...CLASSIFIER_FIELD_VALUES].sort()).toEqual(expected); + }); + + it("matches the property_overrides override_component enum", () => { + // The finaliser maps classifier category -> override_component with no + // translation, so the two vocabularies must be identical. + expect([...OverrideComponentValues].sort()).toEqual(expected); + }); + + it("every category has a non-empty value list that includes Unknown", () => { + // 'Unknown' is the classifier's fallback and the token the finalise gate + // (ADR-0006) blocks on; a category missing it would let Unknowns slip past. + for (const category of LANDLORD_CATEGORIES) { + expect(CATEGORY_VALUES[category].length).toBeGreaterThan(0); + expect(CATEGORY_VALUES[category]).toContain("Unknown"); + } + }); + + it("isLandlordCategory accepts wired categories and rejects others", () => { + for (const category of LANDLORD_CATEGORIES) { + expect(isLandlordCategory(category)).toBe(true); + } + expect(isLandlordCategory("address_1")).toBe(false); + expect(isLandlordCategory("not_a_category")).toBe(false); + }); +}); diff --git a/src/lib/landlordOverrides/categories.ts b/src/lib/landlordOverrides/categories.ts new file mode 100644 index 00000000..4c09ea31 --- /dev/null +++ b/src/lib/landlordOverrides/categories.ts @@ -0,0 +1,98 @@ +import type { PgTableWithColumns } from "drizzle-orm/pg-core"; +import { + landlordPropertyTypeOverrides, + landlordBuiltFormTypeOverrides, + landlordWallTypeOverrides, + landlordRoofTypeOverrides, + landlordMainFuelOverrides, + landlordGlazingOverrides, + landlordConstructionAgeBandOverrides, + landlordWaterHeatingOverrides, + landlordMainHeatingSystemOverrides, + PropertyTypeValues, + BuiltFormTypeValues, + WallTypeValues, + RoofTypeValues, + MainFuelValues, + GlazingValues, + ConstructionAgeBandValues, + WaterHeatingValues, + MainHeatingSystemValues, +} from "@/app/db/schema/landlord_overrides"; + +// Single source of truth for the nine landlord-override categories. Adding a +// category here (alongside its schema table + value enum) wires it through the +// whole app layer at once: the read-only page, the verify/edit dropdowns, the +// Unknown finalise gate (ADR-0006), and user-edit validation. This registry +// replaces the per-category `switch` statements that previously had to be +// extended in ~6 places — a category defined in the schema but missing from one +// of those switches silently rendered as "not classified" (that is exactly what +// happened to the five non-core categories). The completeness test in +// categories.test.ts fails if this set ever drifts from CLASSIFIER_FIELDS or the +// value-enum exports. +// +// Order is display order (read-only page + verify panel) and intentionally +// mirrors CLASSIFIER_FIELDS. It is decoupled from the DB enum member order, +// which Postgres cannot reorder anyway — see docs/adr/0002. +export const LANDLORD_CATEGORIES = [ + "property_type", + "built_form_type", + "wall_type", + "roof_type", + "main_fuel", + "glazing", + "construction_age_band", + "water_heating", + "main_heating_system", +] as const; + +export type LandlordOverrideCategory = (typeof LANDLORD_CATEGORIES)[number]; + +// Every landlord_*_overrides table is structurally identical — id, portfolio_id, +// description, value, source, timestamps — differing only in +// the `value` enum's allowed strings. We deliberately erase that per-table enum +// type here so a single generic helper can serve all nine tables; the switches +// this registry replaced kept the typing, but every consumer already reads and +// writes `value` as a plain string and validates it against `values` at runtime. +// The `any` is confined to the table reference; `values` stays fully typed. +type LandlordOverrideTable = PgTableWithColumns<{ + name: string; + schema: undefined; + columns: any; + dialect: "pg"; +}>; + +export interface CategoryConfig { + table: LandlordOverrideTable; + values: readonly string[]; +} + +export const CATEGORY_TABLES: Record = { + property_type: { table: landlordPropertyTypeOverrides, values: PropertyTypeValues }, + built_form_type: { table: landlordBuiltFormTypeOverrides, values: BuiltFormTypeValues }, + wall_type: { table: landlordWallTypeOverrides, values: WallTypeValues }, + roof_type: { table: landlordRoofTypeOverrides, values: RoofTypeValues }, + main_fuel: { table: landlordMainFuelOverrides, values: MainFuelValues }, + glazing: { table: landlordGlazingOverrides, values: GlazingValues }, + construction_age_band: { + table: landlordConstructionAgeBandOverrides, + values: ConstructionAgeBandValues, + }, + water_heating: { table: landlordWaterHeatingOverrides, values: WaterHeatingValues }, + main_heating_system: { + table: landlordMainHeatingSystemOverrides, + values: MainHeatingSystemValues, + }, +}; + +// Valid enum values per category — populates the verify-panel edit dropdowns +// (client) and validates a user correction (server). Derived from the registry +// so it can never drift from the table set. +export const CATEGORY_VALUES: Record = + Object.fromEntries( + LANDLORD_CATEGORIES.map((category) => [category, CATEGORY_TABLES[category].values]), + ) as Record; + +export function isLandlordCategory(field: string): field is LandlordOverrideCategory { + return field in CATEGORY_TABLES; +} diff --git a/src/lib/landlordOverrides/server.ts b/src/lib/landlordOverrides/server.ts index c8f98eab..8ee984b7 100644 --- a/src/lib/landlordOverrides/server.ts +++ b/src/lib/landlordOverrides/server.ts @@ -1,11 +1,12 @@ import { db } from "@/app/db/db"; -import { - landlordPropertyTypeOverrides, - landlordBuiltFormTypeOverrides, - landlordWallTypeOverrides, - landlordRoofTypeOverrides, -} from "@/app/db/schema/landlord_overrides"; import { asc, eq } from "drizzle-orm"; +import { + CATEGORY_TABLES, + LANDLORD_CATEGORIES, + type LandlordOverrideCategory, +} from "./categories"; + +export type { LandlordOverrideCategory } from "./categories"; export interface OverrideRow { description: string; @@ -13,67 +14,40 @@ export interface OverrideRow { source: string; } -export type LandlordOverrideCategory = - | "property_type" - | "built_form_type" - | "wall_type" - | "roof_type"; - export type LandlordOverrideResults = Record; -const EMPTY: LandlordOverrideResults = { - property_type: [], - built_form_type: [], - wall_type: [], - roof_type: [], -}; +function emptyResults(): LandlordOverrideResults { + const out = {} as LandlordOverrideResults; + for (const category of LANDLORD_CATEGORIES) out[category] = []; + return out; +} -// Reads the four landlord_*_overrides tables for a portfolio. The portfolio id -// is a bigint FK; the bulk-upload flow carries it as a numeric string. +// Reads every landlord_*_overrides table for a portfolio, one query per +// category, keyed by category. The portfolio id is a bigint FK; the bulk-upload +// flow carries it as a numeric string. export async function getLandlordOverrides( - portfolioId: string + portfolioId: string, ): Promise { - if (!/^\d+$/.test(portfolioId)) return EMPTY; + if (!/^\d+$/.test(portfolioId)) return emptyResults(); const pid = BigInt(portfolioId); - const [property_type, built_form_type, wall_type, roof_type] = await Promise.all([ - db - .select({ - description: landlordPropertyTypeOverrides.description, - value: landlordPropertyTypeOverrides.value, - source: landlordPropertyTypeOverrides.source, - }) - .from(landlordPropertyTypeOverrides) - .where(eq(landlordPropertyTypeOverrides.portfolioId, pid)) - .orderBy(asc(landlordPropertyTypeOverrides.description)), - db - .select({ - description: landlordBuiltFormTypeOverrides.description, - value: landlordBuiltFormTypeOverrides.value, - source: landlordBuiltFormTypeOverrides.source, - }) - .from(landlordBuiltFormTypeOverrides) - .where(eq(landlordBuiltFormTypeOverrides.portfolioId, pid)) - .orderBy(asc(landlordBuiltFormTypeOverrides.description)), - db - .select({ - description: landlordWallTypeOverrides.description, - value: landlordWallTypeOverrides.value, - source: landlordWallTypeOverrides.source, - }) - .from(landlordWallTypeOverrides) - .where(eq(landlordWallTypeOverrides.portfolioId, pid)) - .orderBy(asc(landlordWallTypeOverrides.description)), - db - .select({ - description: landlordRoofTypeOverrides.description, - value: landlordRoofTypeOverrides.value, - source: landlordRoofTypeOverrides.source, - }) - .from(landlordRoofTypeOverrides) - .where(eq(landlordRoofTypeOverrides.portfolioId, pid)) - .orderBy(asc(landlordRoofTypeOverrides.description)), - ]); + const entries = await Promise.all( + LANDLORD_CATEGORIES.map(async (category) => { + const { table } = CATEGORY_TABLES[category]; + const rows = (await db + .select({ + description: table.description, + value: table.value, + source: table.source, + }) + .from(table) + .where(eq(table.portfolioId, pid)) + .orderBy(asc(table.description))) as OverrideRow[]; + return [category, rows] as const; + }), + ); - return { property_type, built_form_type, wall_type, roof_type }; + const out = emptyResults(); + for (const [category, rows] of entries) out[category] = rows; + return out; }