assessment-model/src/lib/modellingRuns/model.ts
Khalim Conn-Kowlessar 35eb681f8f feat(modelling-runs): pure domain core — filters, run record, status, bounds
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 <noreply@anthropic.com>
2026-07-06 16:51:55 +00:00

198 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string>();
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<RunConfigInputs>;
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,
},
};
}