mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
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>
198 lines
6.5 KiB
TypeScript
198 lines
6.5 KiB
TypeScript
/**
|
||
* 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,
|
||
},
|
||
};
|
||
}
|