Merge pull request #182 from Hestia-Homes/cascaded-deletes

Cascaded deletes
This commit is contained in:
KhalimCK 2026-02-25 15:37:27 +00:00 committed by GitHub
commit d6d5accf95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 23102 additions and 58 deletions

View file

@ -0,0 +1,65 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
type MeasureAggregateRow = {
measure_type: string | null;
type: string | null;
includes_battery: boolean | null;
homes_count: number;
total_cost: number | null;
average_cost: number | null;
};
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const pid = BigInt(portfolioId);
const result = await db.execute(sql`
SELECT
r.measure_type,
r.type,
COUNT(DISTINCT r.property_id)::int AS homes_count,
SUM(r.estimated_cost)::float AS total_cost,
AVG(r.estimated_cost)::float AS average_cost
FROM recommendation r
WHERE r.default = true
AND r.already_installed = false
AND EXISTS (
SELECT 1
FROM (
SELECT DISTINCT ON (p.property_id)
p.id
FROM plan p
WHERE p.portfolio_id = ${pid}
AND p.is_default = true
ORDER BY p.property_id, p.created_at DESC
) lp
JOIN plan_recommendations pr
ON pr.plan_id = lp.id
WHERE pr.recommendation_id = r.id
)
GROUP BY
r.measure_type,
r.type
ORDER BY total_cost DESC;
`);
const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({
measureType: row.measure_type ?? "unknown",
type: row.type ?? "unknown",
homesCount: row.homes_count,
totalCost: Number(row.total_cost ?? 0),
averageCost: Number(row.average_cost ?? 0),
// includesBattery: row.includes_battery ?? false,
}));
return NextResponse.json({
portfolioId: Number(portfolioId),
measures,
});
}

View file

@ -0,0 +1,262 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { sapToEpc } from "@/app/utils";
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
/* =======================
Types
======================= */
type ScenarioAggregates = {
n_units: number;
avg_sap: number | null;
avg_carbon: number | null;
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
total_sap_uplift: number | null;
};
type UpgradedAggregates = {
n_units_upgraded: number;
total_cost: number | null;
contingency: number | null;
total_funding: number | null;
};
type PortfolioAggregates = {
avg_sap: number | null;
avg_carbon: number | null;
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
};
type EpcRow = {
effective_sap: number | null;
};
/* =======================
Constants
======================= */
const EPC_MIN_SAP: Record<string, number> = {
A: 92,
B: 81,
C: 69,
D: 55,
E: 39,
F: 21,
G: 0,
};
/* =======================
Route
======================= */
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
if (!portfolioId || portfolioId === "null") {
return NextResponse.json({ error: "Invalid portfolioId" }, { status: 400 });
}
const pid = BigInt(portfolioId);
const hideNonCompliant =
request.nextUrl.searchParams.get("hideNonCompliant") === "true";
/* ----------------------------------------------------------
QUERY 1 Scenario metrics (PLANS ONLY)
---------------------------------------------------------- */
const scenarioMetricsResult = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND is_default = true
ORDER BY property_id, created_at DESC
)
SELECT
COUNT(*)::int AS n_units,
AVG(post_sap_points)::float AS avg_sap,
AVG(post_co2_emissions)::float AS avg_carbon,
AVG(post_energy_bill)::float AS avg_bills,
SUM(post_co2_emissions)::float AS total_carbon,
SUM(post_energy_bill)::float AS total_bills,
SUM(
CASE
WHEN cost_of_works > 0
AND post_sap_points IS NOT NULL
THEN post_sap_points - p.current_sap_points
ELSE 0
END
)::float AS total_sap_uplift
FROM latest_plans lp
JOIN property p ON p.id = lp.property_id;
`);
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
/* ----------------------------------------------------------
QUERY 1b Upgrade costs (PLANS ONLY)
---------------------------------------------------------- */
const upgradedResult = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND is_default = true
ORDER BY property_id, created_at DESC
)
SELECT
COUNT(*)::int AS n_units_upgraded,
SUM(cost_of_works)::float AS total_cost,
SUM(contingency_cost)::float AS contingency,
SUM(
COALESCE(fp.project_funding, 0) +
COALESCE(fp.total_uplift, 0)
)::float AS total_funding
FROM latest_plans lp
LEFT JOIN funding_package fp ON fp.plan_id = lp.id
WHERE lp.cost_of_works > 0;
`);
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
/* ----------------------------------------------------------
QUERY 2 Portfolio AFTER scenario (ALL properties)
---------------------------------------------------------- */
const portfolioMetricsResult = await db.execute(sql`
SELECT
AVG(effective_sap)::float AS avg_sap,
AVG(effective_carbon)::float AS avg_carbon,
AVG(effective_bills)::float AS avg_bills,
SUM(effective_carbon)::float AS total_carbon,
SUM(effective_bills)::float AS total_bills
FROM (
SELECT
/* ---------- SAP ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_sap_points
ELSE p.current_sap_points
END AS effective_sap,
/* ---------- Carbon ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions
ELSE e.co2_emissions
END AS effective_carbon,
/* ---------- Bills ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
ELSE (
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)
END AS effective_bills
FROM property p
LEFT JOIN property_details_epc e
ON e.property_id = p.id
LEFT JOIN LATERAL (
SELECT *
FROM plan
WHERE plan.property_id = p.id
AND plan.portfolio_id = ${pid}
AND plan.is_default = true
ORDER BY created_at DESC
LIMIT 1
) lp ON true
WHERE p.portfolio_id = ${pid}
) q;
`);
const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates;
/* ----------------------------------------------------------
QUERY 3 EPC band distribution (ALL properties)
---------------------------------------------------------- */
const epcRows = await db.execute(sql`
SELECT
CASE
WHEN lp.id IS NOT NULL THEN lp.post_sap_points
ELSE p.current_sap_points
END AS effective_sap
FROM property p
LEFT JOIN LATERAL (
SELECT *
FROM plan
WHERE plan.property_id = p.id
AND plan.portfolio_id = ${pid}
AND plan.is_default = true
ORDER BY created_at DESC
LIMIT 1
) lp ON true
WHERE p.portfolio_id = ${pid};
`);
const scenario_epc_counts: Record<string, number> = {
A: 0,
B: 0,
C: 0,
D: 0,
E: 0,
F: 0,
G: 0,
Unknown: 0,
};
for (const row of epcRows.rows as EpcRow[]) {
const band = sapToEpc(row.effective_sap);
scenario_epc_counts[band] += 1;
}
/* ----------------------------------------------------------
RESPONSE
---------------------------------------------------------- */
const constructionCost = upgraded.total_cost ?? 0;
const nUpgraded = upgraded.n_units_upgraded ?? 0;
const pc_cost = constructionCost * 0.3;
return NextResponse.json({
/* -------- portfolio-after-scenario -------- */
avg_sap:
portfolioAgg.avg_sap !== null
? Number(portfolioAgg.avg_sap).toFixed(1)
: null,
avg_carbon: portfolioAgg.avg_carbon,
avg_bills: portfolioAgg.avg_bills,
total_carbon: portfolioAgg.total_carbon,
total_bills: portfolioAgg.total_bills,
/* -------- scenario-only -------- */
n_units: scenarioAgg.n_units,
n_units_upgraded: nUpgraded,
construction_cost: constructionCost,
contingency: upgraded.contingency ?? 0,
total_funding: upgraded.total_funding ?? 0,
net_cost: constructionCost - (upgraded.total_funding ?? 0),
total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0,
gross_per_unit:
nUpgraded > 0 ? (constructionCost + pc_cost) / nUpgraded : 0,
/* -------- shared -------- */
scenario_epc_counts,
pc_cost,
});
}

View file

@ -0,0 +1,6 @@
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_plan_id_plan_id_fk";
--> statement-breakpoint
ALTER TABLE "recommendation_materials" DROP CONSTRAINT "recommendation_materials_recommendation_id_recommendation_id_fk";
--> statement-breakpoint
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_plan_id_plan_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plan"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recommendation_materials" ADD CONSTRAINT "recommendation_materials_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk";
--> statement-breakpoint
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_plan_id_plan_id_fk";
--> statement-breakpoint
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_plan_id_plan_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plan"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,3 @@
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk";
--> statement-breakpoint
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1051,6 +1051,34 @@
"when": 1769597155526,
"tag": "0149_rich_luminals",
"breakpoints": true
},
{
"idx": 150,
"version": "7",
"when": 1771753702175,
"tag": "0150_green_switch",
"breakpoints": true
},
{
"idx": 151,
"version": "7",
"when": 1771754445853,
"tag": "0151_regular_lila_cheney",
"breakpoints": true
},
{
"idx": 152,
"version": "7",
"when": 1771754572720,
"tag": "0152_sparkling_kat_farrell",
"breakpoints": true
},
{
"idx": 153,
"version": "7",
"when": 1771757665072,
"tag": "0153_large_machine_man",
"breakpoints": true
}
]
}

View file

@ -95,15 +95,15 @@ export const recommendation = pgTable(
index("idx_recommendation_active_defaults")
.on(table.id)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
sql`${table.default} = true AND ${table.alreadyInstalled} = false`,
),
index("idx_recommendation_active_id_property")
.on(table.id, table.propertyId)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
sql`${table.default} = true AND ${table.alreadyInstalled} = false`,
),
]
],
);
export const unitQuantity: [string, ...string[]] = ["m2", "part", "kwp"];
@ -117,7 +117,7 @@ export const recommendationMaterials = pgTable(
mode: "bigint",
})
.notNull()
.references(() => recommendation.id),
.references(() => recommendation.id, { onDelete: "cascade" }),
materialId: bigint("material_id", { mode: "bigint" })
.notNull()
.references(() => material.id),
@ -129,9 +129,9 @@ export const recommendationMaterials = pgTable(
},
(table) => [
index("recommendation_materials_recommendation_id_idx").on(
table.recommendationId
table.recommendationId,
),
]
],
);
// We create a plan type, for common plan types that we produce for clients
@ -165,7 +165,7 @@ export const plan = pgTable(
.references(() => property.id),
scenarioId: bigint("scenario_id", { mode: "bigint" }).references(
() => scenario.id
() => scenario.id,
),
createdAt: timestamp("created_at").notNull().defaultNow(),
@ -220,15 +220,15 @@ export const plan = pgTable(
(table) => [
index("idx_plan_portfolio_scenario").on(
table.portfolioId,
table.scenarioId
table.scenarioId,
),
index("idx_plan_latest_per_property").on(
table.portfolioId,
table.scenarioId,
table.propertyId,
table.createdAt.desc()
table.createdAt.desc(),
),
]
],
);
export const planRecommendations = pgTable(
@ -248,9 +248,9 @@ export const planRecommendations = pgTable(
index("idx_plan_recommendations_plan_id").on(table.planId),
index("idx_plan_recommendations_plan_rec").on(
table.planId,
table.recommendationId
table.recommendationId,
),
]
],
);
export const HousingType: [string, ...string[]] = ["Private", "Social"];
@ -273,7 +273,7 @@ export const scenario = pgTable("scenario", {
alreadyInstalledFilePath: text("already_installed_file_path"),
patchesFilePath: text("patches_file_path"),
nonInvasideRecommendationsFilePath: text(
"non_invasive_recommendations_file_path"
"non_invasive_recommendations_file_path",
),
exclusions: text("exclusions"),
multiPlan: boolean("multi_plan"),
@ -298,10 +298,10 @@ export const scenario = pgTable("scenario", {
energyBillPerUnitPreRetrofit: text("energy_bill_per_unit_pre_retrofit"),
energyBillPerUnitPostRetrofit: text("energy_bill_per_unit_post_retrofit"),
energyConsumptionPerUnitPreRetrofit: text(
"energy_consumption_per_unit_pre_retrofit"
"energy_consumption_per_unit_pre_retrofit",
),
energyConsumptionPerUnitPostRetrofit: text(
"energy_consumption_per_unit_post_retrofit"
"energy_consumption_per_unit_post_retrofit",
),
valuationImprovementPerUnit: text("valuation_improvement_per_unit"),
costPerUnit: text("cost_per_unit"),
@ -345,7 +345,7 @@ export const installedMeasure = pgTable(
index("idx_installed_measure_uprn_measure")
.on(table.uprn, table.measureType)
.where(sql`${table.isActive} = true`),
]
],
);
export type Plan = InferModel<typeof plan, "select">;
@ -460,7 +460,7 @@ export const measuresDisplayLabels = {
export type MeasureKey = keyof typeof measuresDisplayLabels;
export const measuresList: MeasureKey[] = Object.keys(
measuresDisplayLabels
measuresDisplayLabels,
) as MeasureKey[];
export const MeasureKeyEnum = z.enum([

View file

@ -39,19 +39,21 @@ async function fetchScenarioReport({
hideNonCompliant,
}: {
portfolioId: number;
scenarioId: number;
hideNonCompliant: boolean; /* this will remove plans that do not meet upgrade targets*/
scenarioId: number | "default";
hideNonCompliant: boolean;
}) {
const params = new URLSearchParams({
hideNonCompliant: String(hideNonCompliant),
});
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics?${params.toString()}`,
);
const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`;
const res = await fetch(`${path}?${params.toString()}`);
if (!res.ok) {
console.error("Failed to fetch scenario report:", await res.text());
throw new Error("Failed to load scenario report");
}
return res.json();
}
@ -60,11 +62,11 @@ async function fetchScenarioMeasures({
scenarioId,
}: {
portfolioId: number;
scenarioId: number;
scenarioId: number | "default";
}) {
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`,
);
const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`;
const res = await fetch(path);
if (!res.ok) {
throw new Error("Failed to load measures");
@ -79,9 +81,9 @@ export function ReportingClientArea({
scenarios,
portfolioId,
}: ReportingClientAreaProps) {
const [selectedScenarioId, setSelectedScenarioId] = useState<number | null>(
null,
);
const [selectedScenarioId, setSelectedScenarioId] = useState<
number | "default" | null
>(null);
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(false);
@ -109,7 +111,7 @@ export function ReportingClientArea({
scenarioId: selectedScenarioId!,
hideNonCompliant: appliedHideNonCompliant,
}),
enabled: !!selectedScenarioId, // only run when scenario selected
enabled: selectedScenarioId !== null, // only run when scenario selected or default selected
keepPreviousData: true, // keep showing old data while loading new scenario or applying filter
refetchOnWindowFocus: false,
});
@ -234,6 +236,9 @@ export function ReportingClientArea({
<ReportingFunctionalityButtons
hideNonCompliant={appliedHideNonCompliant}
disabled={scenarioBusy}
canFilterNonCompliant={
selectedScenarioId !== null && selectedScenarioId !== "default"
}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
}}

View file

@ -20,12 +20,16 @@ export interface ReportingFunctionalityButtonsProps {
onApply: (value: boolean) => Promise<void> | void;
disabled?: boolean;
/* Whether hideNonCompliant filter is available */
canFilterNonCompliant?: boolean;
}
export function ReportingFunctionalityButtons({
hideNonCompliant,
onApply,
disabled = false,
canFilterNonCompliant = true,
}: ReportingFunctionalityButtonsProps) {
const [draftHideNonCompliant, setDraftHideNonCompliant] =
useState<boolean>(hideNonCompliant);
@ -97,10 +101,15 @@ export function ReportingFunctionalityButtons({
>
<div className="space-y-5">
{/* Filter option */}
<div className="flex items-start gap-4">
<div
className={`flex items-start gap-4 ${
!canFilterNonCompliant ? "opacity-50 pointer-events-none" : ""
}`}
>
<Checkbox
id="hide-non-compliant"
checked={draftHideNonCompliant}
disabled={!canFilterNonCompliant}
onCheckedChange={(checked) =>
setDraftHideNonCompliant(Boolean(checked))
}
@ -136,7 +145,7 @@ export function ReportingFunctionalityButtons({
<Button
variant="ghost"
size="sm"
disabled={isApplying}
disabled={isApplying || !canFilterNonCompliant}
onClick={handleReset}
>
Reset
@ -145,7 +154,7 @@ export function ReportingFunctionalityButtons({
<Button
size="sm"
className="bg-brandmidblue hover:bg-hoverblue"
disabled={isApplying}
disabled={isApplying || !canFilterNonCompliant}
onClick={handleApply}
>
{isApplying ? "Applying…" : "Apply filters"}

View file

@ -16,8 +16,8 @@ export interface ScenarioOption {
interface ScenarioSelectorProps {
scenarios: ScenarioOption[];
selected: number | null;
onChange: (id: number | null) => void;
selected: number | null | "default";
onChange: (id: number | null | "default") => void;
}
export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
@ -30,9 +30,16 @@ export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
<span className="text-sm text-gray-600">Scenario:</span>
<Select
value={selected ? String(selected) : "none"}
value={
selected === null
? "none"
: selected === "default"
? "default"
: String(selected)
}
onValueChange={(val) => {
if (val === "none") onChange(null);
else if (val === "default") onChange("default");
else onChange(Number(val));
}}
>
@ -43,6 +50,10 @@ export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
<SelectContent>
<SelectItem value="none">No scenario (baseline only)</SelectItem>
<SelectItem value="default">
Best option (recommended plans)
</SelectItem>
{scenarios.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useMemo } from "react";
import { ScenarioSelector } from "./scenarioSelector";
export function ScenarioSelectorWrapper({
@ -11,23 +11,37 @@ export function ScenarioSelectorWrapper({
}: {
scenarios: { id: number; name: string }[];
portfolioId: number;
selectedScenarioId: number | null;
setSelectedScenarioId: (id: number | null) => void;
selectedScenarioId: number | null | "default";
setSelectedScenarioId: (id: number | null | "default") => void;
}) {
// The ID we will eventually pass into React Query
// const activeContextId = useMemo(
// () => selectedScenarioId ?? portfolioId,
// [selectedScenarioId, portfolioId]
// );
const [selectedScenarioName, setSelectedScenarioName] = useState<
string | null
>(null);
function handleSelect(id: number | null) {
function handleSelect(id: number | null | "default") {
setSelectedScenarioId(id);
const scenario = scenarios.find((s) => s.id === id);
setSelectedScenarioName(scenario ? scenario.name : null);
}
const selectionMeta = useMemo(() => {
if (selectedScenarioId === null) {
return {
label: "Baseline",
description: "Current portfolio performance",
className: "bg-gray-100 text-gray-600 border border-gray-200",
};
}
if (selectedScenarioId === "default") {
return {
label: "Recommended",
description: "Best upgrade plan per property",
className: "bg-brandmidblue text-white border border-brandblue",
};
}
const scenario = scenarios.find((s) => s.id === selectedScenarioId);
return {
label: scenario?.name ?? "Scenario",
description: "Custom upgrade scenario",
className: "bg-white text-gray-700 border border-gray-300",
};
}, [selectedScenarioId, scenarios]);
return (
<div className="flex items-center gap-4">
@ -37,13 +51,20 @@ export function ScenarioSelectorWrapper({
onChange={handleSelect}
/>
{selectedScenarioId !== null ? (
<div className="text-xs text-gray-500">
Scenario selected: {selectedScenarioName}
<div className="flex items-center gap-3">
<div
className={`
inline-flex items-center rounded-full px-3 py-1 text-xs font-medium
${selectionMeta.className}
`}
>
{selectionMeta.label}
</div>
) : (
<div className="text-xs text-gray-400">Using portfolio baseline</div>
)}
<span className="text-xs text-gray-400">
{selectionMeta.description}
</span>
</div>
</div>
);
}