From 35eb681f8fe1b84a56229dc4e29ed848ef490736 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 16:51:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(modelling-runs):=20pure=20domain=20core=20?= =?UTF-8?q?=E2=80=94=20filters,=20run=20record,=20status,=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD'd per ADR-0008: normaliseRunFilters (canonical postcodes, enum vocabularies + Unknown, 40-postcode cap, absence = all), buildRunRecord / parseRunConfig (app-authored task + config-subtask inputs, null-safe round-trip), selectionSummary, deriveRunStatus (dispatched / in-progress with subtask progress / complete / failed), isLargeRun (10k gate). Co-Authored-By: Claude Fable 5 --- src/lib/modellingRuns/model.test.ts | 134 +++++++++++++++++++ src/lib/modellingRuns/model.ts | 198 ++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 src/lib/modellingRuns/model.test.ts create mode 100644 src/lib/modellingRuns/model.ts diff --git a/src/lib/modellingRuns/model.test.ts b/src/lib/modellingRuns/model.test.ts new file mode 100644 index 00000000..656a72fd --- /dev/null +++ b/src/lib/modellingRuns/model.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + buildRunRecord, + deriveRunStatus, + isLargeRun, + normaliseRunFilters, + parseRunConfig, + selectionSummary, +} from "./model"; + +describe("normaliseRunFilters", () => { + it("canonicalises postcodes, dedupes, and drops empty selections (absent = all)", () => { + const out = normaliseRunFilters({ + postcodes: ["m20 4tf", "M20 4TF", "b93 8su"], + propertyTypes: [], + builtForms: undefined, + }); + expect(out).toEqual({ + ok: true, + filters: { postcodes: ["B93 8SU", "M20 4TF"] }, + }); + }); + + it("accepts canonical types/built forms plus Unknown, rejects anything else", () => { + const ok = normaliseRunFilters({ + propertyTypes: ["House", "Unknown"], + builtForms: ["Semi-Detached"], + }); + expect(ok).toEqual({ + ok: true, + filters: { propertyTypes: ["House", "Unknown"], builtForms: ["Semi-Detached"] }, + }); + expect(normaliseRunFilters({ propertyTypes: ["Castle"] }).ok).toBe(false); + expect(normaliseRunFilters({ builtForms: ["Terraced"] }).ok).toBe(false); + }); + + it("caps the postcode selection at 40 — 'all postcodes' is absence, not enumeration", () => { + const many = Array.from({ length: 41 }, (_, i) => `M${Math.floor(i / 10)}${i % 10} 4TF`); + const out = normaliseRunFilters({ postcodes: many }); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.error).toMatch(/40/); + expect(normaliseRunFilters({ postcodes: many.slice(0, 40) }).ok).toBe(true); + }); +}); + +describe("buildRunRecord", () => { + it("produces the app-authored task row and config-subtask inputs (ADR-0008)", () => { + const record = buildRunRecord({ + portfolioId: "814", + scenarioIds: ["12", "34"], + filters: { postcodes: ["B93 8SU"], propertyTypes: ["House"] }, + previewedPropertyCount: 214, + triggeredBy: "khalim@domna.homes", + }); + expect(record.task).toEqual({ + taskSource: "Modelling run – 2 scenarios", + service: "modelling_run", + source: "portfolio_id", + sourceId: "814", + status: "waiting", + }); + expect(record.configInputs).toEqual({ + portfolio_id: 814, + scenario_ids: [12, 34], + filters: { postcodes: ["B93 8SU"], property_types: ["House"] }, + previewed_property_count: 214, + triggered_by: "khalim@domna.homes", + }); + }); +}); + +describe("parseRunConfig", () => { + it("round-trips a stored run record back to the app's shape", () => { + const { configInputs } = buildRunRecord({ + portfolioId: "814", + scenarioIds: ["12"], + filters: { builtForms: ["Detached", "Unknown"] }, + previewedPropertyCount: 57, + triggeredBy: "priya@domna.homes", + }); + expect(parseRunConfig(JSON.stringify(configInputs))).toEqual({ + scenarioIds: ["12"], + filters: { builtForms: ["Detached", "Unknown"] }, + previewedPropertyCount: 57, + triggeredBy: "priya@domna.homes", + }); + }); + + it("returns null for missing or malformed inputs — never throws on old rows", () => { + expect(parseRunConfig(null)).toBeNull(); + expect(parseRunConfig("not json")).toBeNull(); + expect(parseRunConfig('{"unrelated":true}')).toBeNull(); + }); +}); + +describe("selectionSummary", () => { + it("describes the filters in the run-history format; no filters = All properties", () => { + expect(selectionSummary({})).toBe("All properties"); + expect( + selectionSummary({ + postcodes: ["B93 8SU", "M20 4TF"], + propertyTypes: ["House"], + builtForms: ["Detached", "Unknown"], + }), + ).toBe("Postcodes: B93 8SU, M20 4TF · Type: House · Built form: Detached, Unknown"); + }); +}); + +describe("deriveRunStatus", () => { + const base = { taskStatus: "waiting", jobCompleted: null, total: 0, completed: 0, failed: 0 }; + it("maps the task lifecycle to the four display states", () => { + // dispatched: accepted, no execution subtasks yet + expect(deriveRunStatus(base)).toEqual({ state: "dispatched" }); + // in progress once execution subtasks exist, with progress + expect( + deriveRunStatus({ ...base, taskStatus: "in progress", total: 104, completed: 61 }), + ).toEqual({ state: "in_progress", ready: 61, total: 104 }); + // complete when the task says so + expect( + deriveRunStatus({ ...base, taskStatus: "completed", jobCompleted: new Date(), total: 104, completed: 104 }), + ).toEqual({ state: "complete" }); + // failed statuses win regardless of counts + expect( + deriveRunStatus({ ...base, taskStatus: "Failed", total: 104, completed: 40, failed: 3 }), + ).toEqual({ state: "failed" }); + }); +}); + +describe("isLargeRun", () => { + it("gates runs of 10,000+ matched properties behind an extra confirm", () => { + expect(isLargeRun(9999)).toBe(false); + expect(isLargeRun(10000)).toBe(true); + }); +}); diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts new file mode 100644 index 00000000..02cd9e41 --- /dev/null +++ b/src/lib/modellingRuns/model.ts @@ -0,0 +1,198 @@ +/** + * Modelling-run domain model — pure logic for the bulk-trigger-modelling + * journey. See CONTEXT.md (Modelling run, Run filter) and ADR-0008. + */ + +import { normalisePostcode } from "@/lib/postcodeSearch/model"; +import { + BuiltFormTypeValues, + PropertyTypeValues, +} from "@/app/db/schema/landlord_overrides"; + +/** An absent key means unconstrained — "all" is never an enumeration. */ +export interface RunFilters { + postcodes?: string[]; + propertyTypes?: string[]; + builtForms?: string[]; +} + +export type NormaliseFiltersResult = + | { ok: true; filters: RunFilters } + | { ok: false; error: string }; + +/** UX guard, not a transport limit — the config is stored, not carried. */ +export const MAX_FILTER_POSTCODES = 40; + +export function normaliseRunFilters(input: { + postcodes?: string[]; + propertyTypes?: string[]; + builtForms?: string[]; +}): NormaliseFiltersResult { + const filters: RunFilters = {}; + if (input.postcodes?.length) { + const seen = new Set(); + for (const raw of input.postcodes) { + const pc = normalisePostcode(raw); + if (!pc) return { ok: false, error: `Invalid postcode '${raw}'` }; + seen.add(pc); + } + filters.postcodes = [...seen].sort(); + if (filters.postcodes.length > MAX_FILTER_POSTCODES) { + return { + ok: false, + error: `Filters are limited to ${MAX_FILTER_POSTCODES} postcodes per run — leave the filter empty to include all postcodes`, + }; + } + } + // The filter vocabularies are the canonical enum lists (which already end + // in "Unknown" — the no-resolvable-value bucket, see CONTEXT.md Run filter). + for (const [key, allowed] of [ + ["propertyTypes", PropertyTypeValues], + ["builtForms", BuiltFormTypeValues], + ] as const) { + const values = input[key]; + if (!values?.length) continue; + const bad = values.find((v) => !allowed.includes(v)); + if (bad !== undefined) { + return { ok: false, error: `Invalid ${key === "propertyTypes" ? "property type" : "built form"} '${bad}'` }; + } + filters[key] = [...new Set(values)]; + } + return { ok: true, filters }; +} + +export interface RunConfigView { + scenarioIds: string[]; + filters: RunFilters; + previewedPropertyCount: number; + triggeredBy: string; +} + +/** + * Read a stored config-subtask `inputs` back into the app's shape. Null for + * anything that isn't a modelling-run config (malformed, legacy, absent) — + * history reads must never throw on old rows. + */ +export function parseRunConfig(inputs: string | null): RunConfigView | null { + if (!inputs) return null; + try { + const raw = JSON.parse(inputs) as Partial; + if (!Array.isArray(raw.scenario_ids) || typeof raw.triggered_by !== "string") { + return null; + } + const filters: RunFilters = {}; + 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; + return { + scenarioIds: raw.scenario_ids.map(String), + filters, + previewedPropertyCount: raw.previewed_property_count ?? 0, + triggeredBy: raw.triggered_by, + }; + } catch { + return null; + } +} + +/** Above this, the UI asks for an explicit extra confirmation before running. */ +export const LARGE_RUN_THRESHOLD = 10_000; + +export function isLargeRun(matchedPropertyCount: number): boolean { + return matchedPropertyCount >= LARGE_RUN_THRESHOLD; +} + +export type RunStatus = + | { state: "dispatched" } + | { state: "in_progress"; ready: number; total: number } + | { state: "complete" } + | { state: "failed" }; + +/** + * Display state of a run from its task + execution-subtask counts (the config + * subtask is excluded by the caller). Status strings follow the task system's + * loose conventions (case-insensitive; completion also signalled by + * job_completed). + */ +export function deriveRunStatus(input: { + taskStatus: string; + jobCompleted: Date | null; + total: number; + completed: number; + failed: number; +}): RunStatus { + const s = input.taskStatus.toLowerCase(); + if (["failed", "failure", "error"].includes(s)) return { state: "failed" }; + if (input.jobCompleted || ["completed", "complete"].includes(s)) { + return { state: "complete" }; + } + if (input.total === 0) return { state: "dispatched" }; + return { state: "in_progress", ready: input.completed, total: input.total }; +} + +/** Human summary of a Run filter, as shown in run history ("All properties"). */ +export function selectionSummary(filters: RunFilters): string { + const bits: 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(", ")}`); + return bits.length ? bits.join(" · ") : "All properties"; +} + +export interface RunRequest { + portfolioId: string; + scenarioIds: string[]; + filters: RunFilters; + previewedPropertyCount: number; + triggeredBy: string; +} + +/** Backend-facing (snake_case) copy of a run's configuration. */ +export interface RunConfigInputs { + portfolio_id: number; + scenario_ids: number[]; + filters: { + postcodes?: string[]; + property_types?: string[]; + built_forms?: string[]; + }; + previewed_property_count: number; + triggered_by: string; +} + +/** + * The rows a Modelling run persists (ADR-0008): an app-authored task (the + * BulkUpload trigger convention) plus the config-subtask inputs that make the + * run's request durable and history-readable. + */ +export function buildRunRecord(req: RunRequest): { + task: { + taskSource: string; + service: "modelling_run"; + source: "portfolio_id"; + sourceId: string; + status: "waiting"; + }; + configInputs: RunConfigInputs; +} { + const filters: RunConfigInputs["filters"] = {}; + 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; + return { + task: { + taskSource: `Modelling run – ${req.scenarioIds.length} scenario${req.scenarioIds.length === 1 ? "" : "s"}`, + service: "modelling_run", + source: "portfolio_id", + sourceId: req.portfolioId, + status: "waiting", + }, + configInputs: { + portfolio_id: Number(req.portfolioId), + scenario_ids: req.scenarioIds.map(Number), + filters, + previewed_property_count: req.previewedPropertyCount, + triggered_by: req.triggeredBy, + }, + }; +}