diff --git a/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts b/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts new file mode 100644 index 00000000..ee8c768f --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { normaliseRunFilters } from "@/lib/modellingRuns/model"; +import { previewModellingRun } from "@/lib/modellingRuns/server"; + +const bodySchema = z.object({ + scenarioIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50), + filters: z + .object({ + postcodes: z.array(z.string()).optional(), + propertyTypes: z.array(z.string()).optional(), + builtForms: z.array(z.string()).optional(), + }) + .default({}), +}); + +// POST — dry-run counts from the backend (same resolution code path as the +// real run; the preview is a promise, not an estimate — ADR-0008). +export async function POST( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + const normalised = normaliseRunFilters(body.filters); + if (!normalised.ok) { + return NextResponse.json({ error: normalised.error }, { status: 400 }); + } + + try { + const result = await previewModellingRun({ + portfolioId, + scenarioIds: [...new Set(body.scenarioIds)], + filters: normalised.filters, + sessionToken: + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value, + }); + if (!result.ok) { + return NextResponse.json({ error: result.message }, { status: result.status }); + } + return NextResponse.json(result.data); + } catch (err) { + console.error("POST /modelling-runs/preview error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts b/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts new file mode 100644 index 00000000..b62f8078 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { normaliseRunFilters } from "@/lib/modellingRuns/model"; +import { + listModellingRuns, + triggerModellingRun, +} from "@/lib/modellingRuns/server"; + +const filtersSchema = z.object({ + postcodes: z.array(z.string()).optional(), + propertyTypes: z.array(z.string()).optional(), + builtForms: z.array(z.string()).optional(), +}); + +const bodySchema = z.object({ + scenarioIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50), + filters: filtersSchema.default({}), + previewedPropertyCount: z.number().int().nonnegative(), +}); + +function sessionToken(request: NextRequest): string | undefined { + return ( + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value + ); +} + +// POST — record + dispatch a Modelling run (ADR-0008) +export async function POST( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + const normalised = normaliseRunFilters(body.filters); + if (!normalised.ok) { + return NextResponse.json({ error: normalised.error }, { status: 400 }); + } + + try { + const outcome = await triggerModellingRun({ + portfolioId, + scenarioIds: [...new Set(body.scenarioIds)], + filters: normalised.filters, + previewedPropertyCount: body.previewedPropertyCount, + triggeredBy: session.user.email, + sessionToken: sessionToken(request), + }); + switch (outcome.kind) { + case "ok": + return NextResponse.json({ taskId: outcome.taskId }, { status: 202 }); + case "unknown_scenarios": + return NextResponse.json( + { error: `Unknown scenarios for this portfolio: ${outcome.missing.join(", ")}` }, + { status: 400 }, + ); + case "dispatch_failed": + return NextResponse.json( + { error: "Couldn't start modelling — please try again" }, + { status: outcome.status }, + ); + } + } catch (err) { + console.error("POST /modelling-runs error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// GET — run history for the portfolio +export async function GET( + _request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + try { + return NextResponse.json({ runs: await listModellingRuns(portfolioId) }); + } catch (err) { + console.error("GET /modelling-runs error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/postcodes/route.ts b/src/app/api/portfolio/[portfolioId]/postcodes/route.ts new file mode 100644 index 00000000..0801470b --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/postcodes/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { property } from "@/app/db/schema/property"; +import { and, asc, count, eq, isNotNull } from "drizzle-orm"; + +// GET — distinct postcodes (with counts) for the Run-filter options. One +// grouped, portfolio-scoped query (ix_property_portfolio). +export async function GET( + _request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + try { + const rows = await db + .select({ postcode: property.postcode, n: count() }) + .from(property) + .where( + and( + eq(property.portfolioId, BigInt(portfolioId)), + eq(property.markedForDeletion, false), + isNotNull(property.postcode), + ), + ) + .groupBy(property.postcode) + .orderBy(asc(property.postcode)); + return NextResponse.json({ + postcodes: rows.map((r) => ({ postcode: r.postcode, count: r.n })), + }); + } catch (err) { + console.error("GET /postcodes error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 8fd7aed1..21f644b9 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -14,6 +14,7 @@ import { PlusIcon, MagnifyingGlassIcon, DocumentArrowUpIcon, + PlayIcon, } from "@heroicons/react/24/outline"; import { useRouter } from "next/navigation"; import { HomeIcon } from "@heroicons/react/24/outline"; @@ -672,6 +673,15 @@ export default function PropertyTable({ + + {/* Run modelling */} + diff --git a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx new file mode 100644 index 00000000..34202925 --- /dev/null +++ b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx @@ -0,0 +1,582 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { PlayIcon } from "@heroicons/react/24/solid"; +import { + RUN_FILTER_BUILT_FORMS, + RUN_FILTER_PROPERTY_TYPES, + RunFilters, + RunStatus, + isLargeRun, + MAX_FILTER_POSTCODES, +} from "@/lib/modellingRuns/model"; + +export interface ScenarioOption { + id: string; + name: string; + goal: string; + goalValue: string | null; + housingType: string; + budget: number | null; + status: "modelled" | "awaiting" | "running"; + modelledCount: number; +} + +interface PreviewResponse { + matchedProperties: number; + perScenario: { scenarioId: string; alreadyModelled: number }[]; +} + +interface RunHistoryRow { + taskId: string; + triggeredAt: string; + triggeredBy: string; + scenarios: { id: string; name: string | null }[]; + selection: string; + previewedPropertyCount: number; + planCount: number; + status: RunStatus; +} + +const STATUS_LABELS: Record = { + dispatched: "Dispatched", + in_progress: "In progress", + complete: "Complete", + failed: "Failed", +}; + +function RunBadge({ status }: { status: ScenarioOption["status"] }) { + if (status === "running") { + return ( + + + Modelling in progress + + ); + } + const modelled = status === "modelled"; + return ( + + + {modelled ? "Modelled" : "Awaiting modelling"} + + ); +} + +function FilterColumn({ + title, + options, + selected, + onToggle, +}: { + title: string; + options: { value: string; count?: number }[]; + selected: Set; + onToggle: (value: string) => void; +}) { + return ( +
+
+ + {title} + + + · {selected.size ? `${selected.size} selected` : "all"} + +
+
+ {options.map((o) => { + const on = selected.has(o.value); + return ( + + ); + })} +
+
+ ); +} + +export default function RunModellingClient({ + portfolioId, + portfolioName, + scenarios, +}: { + portfolioId: string; + portfolioName: string; + scenarios: ScenarioOption[]; +}) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [selScenarios, setSelScenarios] = useState>(new Set()); + const [selPcs, setSelPcs] = useState>(new Set()); + const [selTypes, setSelTypes] = useState>(new Set()); + const [selForms, setSelForms] = useState>(new Set()); + const [confirmOpen, setConfirmOpen] = useState(false); + const [done, setDone] = useState<{ properties: number; scenarios: number } | null>(null); + + const filters: RunFilters = { + ...(selPcs.size ? { postcodes: [...selPcs].sort() } : {}), + ...(selTypes.size ? { propertyTypes: [...selTypes] } : {}), + ...(selForms.size ? { builtForms: [...selForms] } : {}), + }; + const scenarioIds = [...selScenarios].sort(); + + const postcodesQuery = useQuery<{ postcode: string; count: number }[], Error>({ + queryKey: ["portfolio-postcodes", portfolioId], + staleTime: 5 * 60 * 1000, + queryFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/postcodes`); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't load postcodes"); + return body.postcodes; + }, + }); + + const preview = useQuery({ + queryKey: ["run-preview", portfolioId, scenarioIds, filters], + enabled: scenarioIds.length > 0 && !done, + keepPreviousData: true, + retry: false, + queryFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenarioIds, filters }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't count the selection"); + return body; + }, + }); + + const history = useQuery({ + queryKey: ["modelling-runs", portfolioId], + refetchInterval: 30_000, + queryFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't load run history"); + return body.runs; + }, + }); + + const run = useMutation({ + mutationFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scenarioIds, + filters, + previewedPropertyCount: preview.data?.matchedProperties ?? 0, + }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Couldn't start modelling — please try again"); + return body; + }, + onSuccess: () => { + setDone({ properties: preview.data?.matchedProperties ?? 0, scenarios: scenarioIds.length }); + setSelScenarios(new Set()); + queryClient.invalidateQueries(["modelling-runs", portfolioId]); + }, + }); + + const toggle = (set: Set, setter: (s: Set) => void, value: string) => { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + setter(next); + }; + + const matched = preview.data?.matchedProperties; + const planTotal = matched != null ? matched * scenarioIds.length : null; + const remodel = (preview.data?.perScenario ?? []).filter((p) => p.alreadyModelled > 0); + const scenarioById = new Map(scenarios.map((s) => [s.id, s])); + const startRun = () => { + if (matched != null && isLargeRun(matched)) setConfirmOpen(true); + else run.mutate(); + }; + const eta = planTotal ? Math.max(5, Math.round(planTotal / 300) * 5) : 5; + + return ( +
+
+ + {portfolioName} / Portfolio + {" "} + / Run modelling +
+

+ Run your scenarios. +

+

+ Choose the scenarios you want answered, then the homes to include. The counts you see are + checked against your portfolio as you go — what you see is exactly what gets modelled. +

+ + {done ? ( +
+
+ +
+

Modelling started

+

+ We're modelling {done.properties.toLocaleString()}{" "} + {done.properties === 1 ? "property" : "properties"} against {done.scenarios}{" "} + scenario{done.scenarios > 1 ? "s" : ""} —{" "} + {(done.properties * done.scenarios).toLocaleString()} plan + {done.properties * done.scenarios === 1 ? "" : "s"} in total. Results for a run this + size are typically ready within ~{eta} minutes. You can check on this run any + time under Recent runs. +

+
+ + +
+
+ ) : ( + <> + {/* Step 1 — scenarios */} +
+ + Step 1 · Scenarios + +

+ Which questions are we asking? +

+

+ Every selected scenario runs against the same property selection. +

+ {scenarios.length === 0 ? ( +
+ No scenarios yet —{" "} + + create your first scenario + {" "} + and come back to run it. +
+ ) : ( + scenarios.map((s) => { + const on = selScenarios.has(s.id); + return ( +
+ + {on && s.status === "running" && ( +
+ This scenario is already being modelled. You can still include it — the + newest results are the ones you'll see. +
+ )} +
+ ); + }) + )} +
+ + {/* Step 2 — properties */} +
+ + Step 2 · Properties + +

+ Which homes? +

+

+ Leave a filter untouched to include everything. What you've told us about a home + takes priority over its EPC; “Unknown” picks out homes where we have + neither. +

+
+ ({ + value: p.postcode, + count: p.count, + }))} + selected={selPcs} + onToggle={(v) => toggle(selPcs, setSelPcs, v)} + /> + ({ value }))} + selected={selTypes} + onToggle={(v) => toggle(selTypes, setSelTypes, v)} + /> + ({ value }))} + selected={selForms} + onToggle={(v) => toggle(selForms, setSelForms, v)} + /> +
+

+ Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run. Tags are coming — + you'll be able to save a selection and reuse it. +

+
+ + {/* Recent runs */} +
+
+

Recent runs

+ + what was asked, by whom, and how it went + +
+ {history.isLoading ? ( +

Loading…

+ ) : !history.data?.length ? ( +

+ No runs yet — your first appears here the moment you trigger it. +

+ ) : ( +
+ + + + {["When", "Scenarios", "Selection", "Properties", "Plans", "Status"].map( + (h) => ( + + ), + )} + + + + {history.data.map((r) => ( + + + + + + + + + ))} + +
+ {h} +
+ {new Date(r.triggeredAt).toLocaleString("en-GB", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + })} + {r.triggeredBy} + + {r.scenarios.map((s) => s.name ?? s.id).join(" · ") || "—"} + + {r.selection} + + {r.previewedPropertyCount.toLocaleString()} + + {r.status.state === "in_progress" ? ( + <> + + {r.status.ready.toLocaleString()} of {r.status.total.toLocaleString()} ready + + + + + + ) : ( + r.planCount.toLocaleString() + )} + + + {STATUS_LABELS[r.status.state]} + +
+
+ )} +
+ + )} + + {/* Summary tray */} + {!done && scenarioIds.length > 0 && ( +
+
+ + {matched != null ? matched.toLocaleString() : "—"} + properties + + × + + {scenarioIds.length} + + scenario{scenarioIds.length > 1 ? "s" : ""} + + + = + + {planTotal != null ? planTotal.toLocaleString() : "—"} + plans + + + + {preview.isFetching + ? "Counting…" + : preview.isError + ? preview.error.message + : "Checked against your portfolio just now"} + + +
+ {(remodel.length > 0 || run.isError) && ( +
+ {remodel.map((p) => ( + + {p.alreadyModelled.toLocaleString()} of{" "} + {matched?.toLocaleString() ?? "—"} have already been modelled in “ + {scenarioById.get(p.scenarioId)?.name ?? p.scenarioId}” — they'll be + modelled again and the newest results shown + + ))} + {run.isError && {run.error.message}} +
+ )} +
+ )} + + {/* Large-run confirm */} + {confirmOpen && ( +
+
+

+ This is a large run +

+

+ You're about to model {matched?.toLocaleString()} homes across{" "} + {scenarioIds.length} scenario{scenarioIds.length > 1 ? "s" : ""} —{" "} + {planTotal?.toLocaleString()} plans. A run this size can't be stopped + once it starts. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/modelling/run/page.tsx b/src/app/portfolio/[slug]/modelling/run/page.tsx new file mode 100644 index 00000000..9a686cb9 --- /dev/null +++ b/src/app/portfolio/[slug]/modelling/run/page.tsx @@ -0,0 +1,38 @@ +import { getPortfolio } from "../../utils"; +import { listScenariosWithStatus } from "@/lib/scenarios/queries"; +import { scenarioIdsWithActiveRuns } from "@/lib/modellingRuns/server"; +import RunModellingClient, { ScenarioOption } from "./RunModellingClient"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { params: Promise<{ slug: string }> }) { + const { slug } = await props.params; + const [{ name }, scenarios, activeIds] = await Promise.all([ + getPortfolio(slug), + listScenariosWithStatus(BigInt(slug)), + scenarioIdsWithActiveRuns(slug), + ]); + + const options: ScenarioOption[] = scenarios.map((s) => ({ + id: s.id.toString(), + name: s.name ?? `Scenario ${s.id}`, + goal: s.goal, + goalValue: s.goalValue, + housingType: s.housingType, + budget: s.budget, + status: activeIds.has(s.id.toString()) + ? "running" + : s.status === "modelled" + ? "modelled" + : "awaiting", + modelledCount: s.plansCount, + })); + + return ( + + ); +} diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts index 02cd9e41..c00fe73a 100644 --- a/src/lib/modellingRuns/model.ts +++ b/src/lib/modellingRuns/model.ts @@ -9,6 +9,13 @@ import { PropertyTypeValues, } from "@/app/db/schema/landlord_overrides"; +/** + * The Run-filter vocabularies: the canonical enum lists, which already end in + * "Unknown" — the no-resolvable-value bucket (CONTEXT.md, Run filter). + */ +export const RUN_FILTER_PROPERTY_TYPES: readonly string[] = PropertyTypeValues; +export const RUN_FILTER_BUILT_FORMS: readonly string[] = BuiltFormTypeValues; + /** An absent key means unconstrained — "all" is never an enumeration. */ export interface RunFilters { postcodes?: string[]; diff --git a/src/lib/modellingRuns/server.ts b/src/lib/modellingRuns/server.ts new file mode 100644 index 00000000..b20a1940 --- /dev/null +++ b/src/lib/modellingRuns/server.ts @@ -0,0 +1,244 @@ +import { db } from "@/app/db/db"; +import { tasks } from "@/app/db/schema/tasks/tasks"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; +import { scenario } from "@/app/db/schema/recommendations"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { + RunFilters, + RunStatus, + buildRunRecord, + deriveRunStatus, + parseRunConfig, + selectionSummary, +} from "./model"; + +// The config subtask that makes a run's request durable (ADR-0008); execution +// subtasks attached by the distributor carry other service values. +export const RUN_CONFIG_SERVICE = "modelling_run_config"; + +// Distributor entry point on the Model backend. It fans the run out to +// workers, attaching execution subtasks to the task_id we pass (ADR-0008). +const DISTRIBUTOR_ENDPOINT = "/v1/plan/trigger-run"; +// Dry-run sibling: same filter resolution, counts only. +const PREVIEW_ENDPOINT = "/v1/plan/preview-run"; + +type BackendResult = { ok: true; data: T } | { ok: false; status: number; message: string }; + +async function callBackend( + endpoint: string, + payload: Record, + sessionToken: string | undefined, +): Promise> { + const url = process.env.FASTAPI_API_URL; + const key = process.env.FASTAPI_API_KEY; + if (!url || !key) { + console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); + return { ok: false, status: 500, message: "Server misconfiguration" }; + } + try { + const res = await fetch(`${url}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": key, + Authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const errText = await res.text().catch(() => ""); + console.error(`FastAPI ${endpoint} failed:`, res.status, errText); + return { ok: false, status: 502, message: "Modelling backend request failed" }; + } + return { ok: true, data: (await res.json()) as T }; + } catch (err) { + console.error(`Failed to reach FastAPI ${endpoint}:`, err); + return { ok: false, status: 502, message: "Modelling backend request failed" }; + } +} + +export type TriggerRunOutcome = + | { kind: "ok"; taskId: string } + | { kind: "unknown_scenarios"; missing: string[] } + | { kind: "dispatch_failed"; status: number; message: string }; + +/** + * Record + dispatch a Modelling run: app-authored task and config subtask + * (BulkUpload trigger convention), then hand task_id to the distributor. A + * failed dispatch marks the task failed so history never shows a ghost run + * as pending (ADR-0008). + */ +export async function triggerModellingRun(args: { + portfolioId: string; + scenarioIds: string[]; + filters: RunFilters; + previewedPropertyCount: number; + triggeredBy: string; + sessionToken: string | undefined; +}): Promise { + const known = await db + .select({ id: scenario.id }) + .from(scenario) + .where( + and( + eq(scenario.portfolioId, BigInt(args.portfolioId)), + inArray(scenario.id, args.scenarioIds.map(BigInt)), + ), + ); + const knownIds = new Set(known.map((r) => r.id.toString())); + const missing = args.scenarioIds.filter((id) => !knownIds.has(id)); + if (missing.length > 0) return { kind: "unknown_scenarios", missing }; + + const record = buildRunRecord(args); + const now = new Date(); + const [task] = await db + .insert(tasks) + .values({ ...record.task, jobStarted: now }) + .returning(); + await db.insert(subTasks).values({ + taskId: task.id, + status: "waiting", + service: RUN_CONFIG_SERVICE, + inputs: JSON.stringify(record.configInputs), + }); + + const dispatch = await callBackend( + DISTRIBUTOR_ENDPOINT, + { task_id: task.id, ...record.configInputs }, + args.sessionToken, + ); + if (!dispatch.ok) { + await db.update(tasks).set({ status: "failed" }).where(eq(tasks.id, task.id)); + return { kind: "dispatch_failed", status: dispatch.status, message: dispatch.message }; + } + + await db.update(tasks).set({ status: "in progress" }).where(eq(tasks.id, task.id)); + return { kind: "ok", taskId: task.id }; +} + +export interface PreviewCounts { + matchedProperties: number; + perScenario: { scenarioId: string; alreadyModelled: number }[]; +} + +/** Proxy the backend dry-run: same resolution code path as the real run. */ +export async function previewModellingRun(args: { + portfolioId: string; + scenarioIds: string[]; + filters: RunFilters; + sessionToken: string | undefined; +}): Promise> { + const { configInputs } = buildRunRecord({ + ...args, + previewedPropertyCount: 0, + triggeredBy: "preview", + }); + const res = await callBackend<{ + matched_properties: number; + per_scenario?: { scenario_id: number; already_modelled: number }[]; + }>( + PREVIEW_ENDPOINT, + { + portfolio_id: configInputs.portfolio_id, + scenario_ids: configInputs.scenario_ids, + filters: configInputs.filters, + }, + args.sessionToken, + ); + if (!res.ok) return res; + return { + ok: true, + data: { + matchedProperties: res.data.matched_properties, + perScenario: (res.data.per_scenario ?? []).map((p) => ({ + scenarioId: String(p.scenario_id), + alreadyModelled: p.already_modelled, + })), + }, + }; +} + +export interface RunHistoryRow { + taskId: string; + triggeredAt: string; + triggeredBy: string; + scenarios: { id: string; name: string | null }[]; + selection: string; + previewedPropertyCount: number; + planCount: number; + status: RunStatus; +} + +/** + * Run history for a portfolio: its modelling_run tasks with app-authored + * config parsed back out and progress from execution-subtask counts. One + * grouped query — portfolio-scoped, never per-run probes. + */ +export async function listModellingRuns( + portfolioId: string, + limit = 20, +): Promise { + const rows = await db + .select({ + taskId: tasks.id, + jobStarted: tasks.jobStarted, + jobCompleted: tasks.jobCompleted, + status: tasks.status, + configInputs: sql`max(${subTasks.inputs}) filter (where ${subTasks.service} = ${RUN_CONFIG_SERVICE})`, + total: sql`count(${subTasks.id}) filter (where ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE})::int`, + completed: sql`count(case when lower(${subTasks.status}) in ('completed','complete') and ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE} then 1 end)::int`, + failed: sql`count(case when lower(${subTasks.status}) in ('failed','failure','error') and ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE} then 1 end)::int`, + }) + .from(tasks) + .leftJoin(subTasks, eq(subTasks.taskId, tasks.id)) + .where(and(eq(tasks.service, "modelling_run"), eq(tasks.sourceId, portfolioId))) + .groupBy(tasks.id) + .orderBy(desc(tasks.jobStarted)) + .limit(limit); + + const configs = rows.map((r) => parseRunConfig(r.configInputs)); + const scenarioIds = [...new Set(configs.flatMap((c) => c?.scenarioIds ?? []))]; + const names = scenarioIds.length + ? await db + .select({ id: scenario.id, name: scenario.name }) + .from(scenario) + .where(inArray(scenario.id, scenarioIds.map(BigInt))) + : []; + const nameById = new Map(names.map((n) => [n.id.toString(), n.name])); + + return rows.map((r, i) => { + const config = configs[i]; + const scenarios = (config?.scenarioIds ?? []).map((id) => ({ + id, + name: nameById.get(id) ?? null, + })); + return { + taskId: r.taskId, + triggeredAt: (r.jobStarted ?? new Date(0)).toISOString(), + triggeredBy: config?.triggeredBy ?? "unknown", + scenarios, + selection: config ? selectionSummary(config.filters) : "—", + previewedPropertyCount: config?.previewedPropertyCount ?? 0, + planCount: (config?.previewedPropertyCount ?? 0) * scenarios.length, + status: deriveRunStatus({ + taskStatus: r.status, + jobCompleted: r.jobCompleted, + total: r.total, + completed: r.completed, + failed: r.failed, + }), + }; + }); +} + +/** Scenario ids with a run currently in flight — drives the third badge state. */ +export async function scenarioIdsWithActiveRuns(portfolioId: string): Promise> { + const runs = await listModellingRuns(portfolioId, 50); + const active = new Set(); + for (const run of runs) { + if (run.status.state === "dispatched" || run.status.state === "in_progress") { + run.scenarios.forEach((s) => active.add(s.id)); + } + } + return active; +}