/** * 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, }, }; }