diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index 8fc292b6..702ebeda 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -3,6 +3,7 @@ import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { sapToEpc } from "@/app/utils"; import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; +import { newApproachJoins, carbonSql, billsSql } from "@/lib/services/epcSources"; /* ======================= Types @@ -208,26 +209,19 @@ export async function GET( /* ---------- Carbon ---------- */ CASE WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions - ELSE e.co2_emissions + ELSE ${carbonSql(sql`e`)} 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) - ) + ELSE ${billsSql(sql`e`)} END AS effective_bills FROM property p LEFT JOIN property_details_epc e ON e.property_id = p.id + ${newApproachJoins} LEFT JOIN LATERAL ( SELECT * diff --git a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts index 6d5df36b..a1ed7257 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts @@ -3,6 +3,7 @@ import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { sapToEpc } from "@/app/utils"; import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; +import { newApproachJoins, carbonSql, billsSql } from "@/lib/services/epcSources"; /* ======================= Types @@ -150,26 +151,19 @@ export async function GET( /* ---------- Carbon ---------- */ CASE WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions - ELSE e.co2_emissions + ELSE ${carbonSql(sql`e`)} 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) - ) + ELSE ${billsSql(sql`e`)} END AS effective_bills FROM property p LEFT JOIN property_details_epc e ON e.property_id = p.id + ${newApproachJoins} LEFT JOIN LATERAL ( SELECT * diff --git a/src/app/api/property-meta/[propertyId]/route.ts b/src/app/api/property-meta/[propertyId]/route.ts index 81f47ae7..f526544d 100644 --- a/src/app/api/property-meta/[propertyId]/route.ts +++ b/src/app/api/property-meta/[propertyId]/route.ts @@ -1,6 +1,7 @@ import { db } from "@/app/db/db"; import { property } from "@/app/db/schema/property"; import { serializeBigInt } from "@/app/utils"; +import { resolveDetailsEpcMeta } from "@/lib/services/epcSources"; import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; @@ -31,20 +32,21 @@ export async function GET(request: NextRequest, props: { params: Promise<{ prope currentValuation: true, }, where: eq(property.id, BigInt(propertyId)), - with: { - detailsEpc: { - columns: { - currentEnergyDemand: true, - co2Emissions: true, - estimated: true, - }, - }, - }, }); if (!propertyMeta) { return new NextResponse(null, { status: 404 }); } - return new NextResponse(JSON.stringify(propertyMeta, serializeBigInt)); + // `detailsEpc` is resolved via the cutoff: legacy properties read + // property_details_epc; new-approach ones read epc_property + + // property_baseline_performance. See src/lib/services/epcSources.ts. + const detailsEpc = await resolveDetailsEpcMeta( + propertyMeta.id, + propertyMeta.updatedAt, + ); + + return new NextResponse( + JSON.stringify({ ...propertyMeta, detailsEpc }, serializeBigInt), + ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index 7dbf1216..c1048dd5 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -1,6 +1,15 @@ import { db } from "@/app/db/db"; import { sql, eq } from "drizzle-orm"; import { scenario } from "@/app/db/schema/recommendations"; +import { + NEW_APPROACH_CUTOFF, + newApproachJoins, + carbonSql, + billsSql, + energyConsumptionSql, + estimatedSql, + isExpiredSql, +} from "@/lib/services/epcSources"; import type { AverageMetrics, @@ -28,19 +37,12 @@ export async function getAverages( const result = await db.execute(sql` SELECT AVG(p.current_sap_points)::float AS avg_sap, - AVG(e.co2_emissions)::float AS avg_carbon, - AVG( - 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) - )::float AS avg_bills, - AVG(e.primary_energy_consumption)::float AS avg_energy_consumption + AVG(${carbonSql(sql`e`)})::float AS avg_carbon, + AVG(${billsSql(sql`e`)})::float AS avg_bills, + AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption FROM property p LEFT JOIN property_details_epc e ON e.property_id = p.id + ${newApproachJoins} WHERE p.portfolio_id = ${portfolioId}; `); @@ -50,18 +52,11 @@ export async function getAverages( export async function getTotals(portfolioId: number): Promise { const result = await db.execute(sql` SELECT - SUM(e.co2_emissions)::float AS total_carbon, - SUM( - 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) - )::float AS total_bills + SUM(${carbonSql(sql`e`)})::float AS total_carbon, + SUM(${billsSql(sql`e`)})::float AS total_bills FROM property p LEFT JOIN property_details_epc e ON e.property_id = p.id + ${newApproachJoins} WHERE p.portfolio_id = ${portfolioId}; `); @@ -104,14 +99,15 @@ export async function getCountByEpcBand( SELECT COALESCE(p.current_epc_rating::text, 'Unknown') AS epc, COUNT(*) FILTER ( - WHERE e.estimated = false OR e.estimated IS NULL + WHERE ${estimatedSql(sql`e`)} = false )::int AS actual, COUNT(*) FILTER ( - WHERE e.estimated = true + WHERE ${estimatedSql(sql`e`)} = true )::int AS estimated FROM property p LEFT JOIN property_details_epc e ON e.property_id = p.id + ${newApproachJoins} WHERE p.portfolio_id = ${portfolioId} GROUP BY epc ) q @@ -136,10 +132,12 @@ export async function getEstimatedCounts( ): Promise { const result = await db.execute(sql` SELECT - SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated, - SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual - FROM property_details_epc e - WHERE e.portfolio_id = ${portfolioId}; + SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated, + SUM(CASE WHEN ${estimatedSql(sql`e`)} = false THEN 1 ELSE 0 END)::int AS actual + FROM property p + LEFT JOIN property_details_epc e ON e.property_id = p.id + ${newApproachJoins} + WHERE p.portfolio_id = ${portfolioId}; `); return result.rows[0]; @@ -163,14 +161,16 @@ export async function getExpiredEpcCount(portfolioId: number): Promise { const result = await db.execute<{ expired: number }>(sql` SELECT SUM( - CASE - WHEN is_expired = true AND estimated = false - THEN 1 - ELSE 0 + CASE + WHEN ${isExpiredSql(sql`e`)} = true AND ${estimatedSql(sql`e`)} = false + THEN 1 + ELSE 0 END )::int AS expired - FROM property_details_epc - WHERE portfolio_id = ${portfolioId}; + FROM property p + LEFT JOIN property_details_epc e ON e.property_id = p.id + ${newApproachJoins} + WHERE p.portfolio_id = ${portfolioId}; `); return result.rows[0].expired; @@ -186,6 +186,9 @@ export async function getLikelyDowngrades( JOIN property_details_epc e ON e.property_id = p.id WHERE p.portfolio_id = ${portfolioId} + -- GAP: the SAP-05 "likely downgrade" signal has no equivalent in the new + -- pipeline (handled there via Rebaseline). Restrict to legacy properties. + AND p.updated_at < ${NEW_APPROACH_CUTOFF}::date AND e.sap_05_overwritten = true AND p.current_sap_points IS NOT NULL AND e.sap_05_score IS NOT NULL diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index c56b477c..0ab77e7b 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -13,14 +13,16 @@ import { surveyDB } from "@/app/db/surveyDB/connection"; import { Feature, GeneralFeature, - PropertyDetailsEpc, PropertyDetailsSpatial, PropertyMeta, - propertyDetailsEpc, propertyDetailsSpatial, NonIntrusiveSurveyData, nonInstrusiveSurvey, } from "@/app/db/schema/property"; +import { + ConditionReport, + resolveConditionReport, +} from "@/lib/services/epcSources"; import { getRating } from "@/app/utils"; import { eq, desc, and } from "drizzle-orm"; import { @@ -250,16 +252,11 @@ export async function getPropertyMeta( export async function getConditionReport( propertyId: string -): Promise { - const data = await db.query.propertyDetailsEpc.findFirst({ - where: eq(propertyDetailsEpc.propertyId, BigInt(propertyId)), - }); - - if (!data) { - throw new Error("Network response was not ok"); - } - - return data; +): Promise { + // Branches on the new-approach cutoff: legacy properties read + // property_details_epc; new-approach ones read the epc_property graph + + // property_baseline_performance. See src/lib/services/epcSources.ts. + return resolveConditionReport(propertyId); } export async function getSpatialData( @@ -298,7 +295,7 @@ export async function getNonIntrusiveSurvey( } export function formatGeneralFeatures( - conditionReportData: PropertyDetailsEpc, + conditionReportData: ConditionReport, propertyType: string ): GeneralFeature[] { // if a property is a flat/maisonette, we show heat loss coridoor information, otherwise we won't show it @@ -353,7 +350,7 @@ export function formatGeneralFeatures( } export function formatRetrofitFeatures( - conditionReportData: PropertyDetailsEpc + conditionReportData: ConditionReport ): Feature[] { // Helper function to convert rating number to string @@ -437,7 +434,7 @@ export function formatRetrofitFeatures( } export function formatHeatDemandFeatures( - conditionReportData: PropertyDetailsEpc + conditionReportData: ConditionReport ): GeneralFeature[] { return [ { diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index e3676c89..cf4ccfac 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -19,6 +19,14 @@ import { ScenarioSelect, } from "@/app/db/schema/recommendations"; import { sql } from "drizzle-orm"; +import { + newApproachJoins, + carbonSql, + totalFloorAreaSql, + lodgementDateSql, + isExpiredSql, + mainfuelSql, +} from "@/lib/services/epcSources"; import { FilterGroups, PropertyFilter, @@ -528,8 +536,11 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul } case "epcExpiryDate": { - const expiryExpr = sql`(epc.lodgement_date + INTERVAL '10 years')`; - const guard = sql`epc.lodgement_date IS NOT NULL`; + // Branches on the cutoff: new-approach properties derive the lodgement + // date from the lodged epc_property row. See epcSources.ts. + const lodgementExpr = lodgementDateSql(sql`epc`); + const expiryExpr = sql`(${lodgementExpr} + INTERVAL '10 years')`; + const guard = sql`${lodgementExpr} IS NOT NULL`; if (filter.operator === "date_before") { return sql`${guard} AND ${expiryExpr} < ${filter.value}::date`; @@ -543,7 +554,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul if (filter.operator === "date_preset") { switch (filter.value) { case "expired": - return sql`epc.is_expired = true`; + return sql`${isExpiredSql(sql`epc`)} = true`; case "expires_this_year": return sql`${guard} AND EXTRACT(YEAR FROM ${expiryExpr}) = EXTRACT(YEAR FROM CURRENT_DATE)`; case "expires_within_1_year": @@ -578,7 +589,9 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul builtForm: sql`p.built_form`, tenure: sql`p.tenure`, yearBuilt: sql`p.year_built`, - mainfuel: sql`epc.mainfuel`, + // GAP: new-approach properties have no main-fuel string → NULL, so they + // only match the "no value" filter branch. See epcSources.ts. + mainfuel: mainfuelSql(sql`epc`), }; const col = colMap[filter.field]; @@ -616,18 +629,20 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul case "floorArea": { const n = parseFloat(filter.value); if (isNaN(n)) return null; - if (filter.operator === "num_gte") return sql`epc.total_floor_area >= ${n}`; - if (filter.operator === "num_lte") return sql`epc.total_floor_area <= ${n}`; - if (filter.operator === "num_equals") return sql`epc.total_floor_area = ${n}`; + const area = totalFloorAreaSql(sql`epc`); + if (filter.operator === "num_gte") return sql`${area} >= ${n}`; + if (filter.operator === "num_lte") return sql`${area} <= ${n}`; + if (filter.operator === "num_equals") return sql`${area} = ${n}`; return null; } case "co2Emissions": { const n = parseFloat(filter.value); if (isNaN(n)) return null; - if (filter.operator === "num_gte") return sql`epc.co2_emissions >= ${n}`; - if (filter.operator === "num_lte") return sql`epc.co2_emissions <= ${n}`; - if (filter.operator === "num_equals") return sql`epc.co2_emissions = ${n}`; + const carbon = carbonSql(sql`epc`); + if (filter.operator === "num_gte") return sql`${carbon} >= ${n}`; + if (filter.operator === "num_lte") return sql`${carbon} <= ${n}`; + if (filter.operator === "num_equals") return sql`${carbon} = ${n}`; return null; } } @@ -666,6 +681,7 @@ export async function getPropertiesCount( SELECT COUNT(DISTINCT p.id)::int AS count FROM property p LEFT JOIN property_details_epc epc ON epc.property_id = p.id + ${newApproachJoins} LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = true WHERE p.portfolio_id = ${portfolioId} ${combinedWhere} @@ -706,11 +722,11 @@ export async function getProperties( p.built_form AS "builtForm", p.tenure AS tenure, p.year_built AS "yearBuilt", - epc.lodgement_date::text AS "epcLodgementDate", - epc.is_expired AS "epcIsExpired", - epc.total_floor_area AS "totalFloorArea", - epc.co2_emissions AS "co2Emissions", - epc.mainfuel AS mainfuel, + ${lodgementDateSql(sql`epc`)}::text AS "epcLodgementDate", + ${isExpiredSql(sql`epc`)} AS "epcIsExpired", + ${totalFloorAreaSql(sql`epc`)} AS "totalFloorArea", + ${carbonSql(sql`epc`)} AS "co2Emissions", + ${mainfuelSql(sql`epc`)} AS mainfuel, p.lexiscore AS lexiscore FROM property p LEFT JOIN property_targets t @@ -728,6 +744,7 @@ export async function getProperties( AND r.already_installed = false LEFT JOIN property_details_epc epc ON epc.property_id = p.id + ${newApproachJoins} WHERE p.portfolio_id = ${portfolioId} ${combinedWhere} GROUP BY @@ -748,11 +765,19 @@ export async function getProperties( p.built_form, p.tenure, p.year_built, + -- legacy + new-source columns referenced by the branched EPC expressions + p.updated_at, epc.lodgement_date, epc.is_expired, epc.total_floor_area, epc.co2_emissions, epc.mainfuel, + epl.id, + epp.id, + epl.registration_date, + epl.inspection_date, + epl.total_floor_area_m2, + bp.effective_co2_emissions_t_per_yr, p.lexiscore LIMIT ${limit} OFFSET ${offset}; `); diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts new file mode 100644 index 00000000..d57a9bae --- /dev/null +++ b/src/lib/services/epcSources.ts @@ -0,0 +1,419 @@ +/** + * EPC read-model resolver. + * + * The legacy plan engine wrote a flat per-property table `property_details_epc`. + * The new modelling pipeline (Hestia-Homes/Model) does NOT write that table — + * it writes the normalized EPC graph (`epc_property` + children), the modelling + * output (`plan` / `recommendation`) and `property_baseline_performance`. + * + * A property is "new approach" when `property.updated_at >= 2026-06-01` (the + * cutoff — the old pipeline predates it). For such properties the app must read + * the new sources and must NOT touch `property_details_epc`. Legacy properties + * keep reading the old table unchanged. + * + * This module is the single place that knows the cutoff and the field→source + * mapping, so callers (building-passport, property-meta, portfolio list, + * reporting + scenario metrics) branch consistently. + * + * Mapping applied (see the handover report for the full table and flagged gaps): + * - EPC fabric descriptions + 1–5 ratings → `epc_energy_element` + * - whole-dwelling counts / flags → `epc_property` (+ `epc_flat_details`) + * - baseline carbon / energy / bills → `property_baseline_performance` + * - post-retrofit data → `plan` / `recommendation` + * - "estimated" flag → property has only a `source='predicted'` + * EPC (no `source='lodged'` row) + * + * Gracefully-omitted gaps for new properties (no clean equivalent — reported to + * the owner): `mainfuel`, `energyTariff`, `floorHeight`, the installed-measures + * bill adjustment, and the SAP-05 "likely downgrade" metric. + */ +import { db } from "@/app/db/db"; +import { and, eq, sql } from "drizzle-orm"; +import { + epcProperty, + epcEnergyElement, + epcFlatDetails, + propertyBaselinePerformance, + propertyDetailsEpc, + property, +} from "@/app/db/schema/property"; + +/** The discriminator: properties updated on/after this date use the new sources. */ +export const NEW_APPROACH_CUTOFF = "2026-06-01"; + +export function isNewApproach(updatedAt: Date | string | null | undefined): boolean { + if (!updatedAt) return false; + const d = updatedAt instanceof Date ? updatedAt : new Date(updatedAt); + return d >= new Date(`${NEW_APPROACH_CUTOFF}T00:00:00.000Z`); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared SQL fragments for set-based aggregate queries. +// +// These assume the standard aliases: +// p = property +// bp = property_baseline_performance (new — baseline carbon/energy/bills) +// epl = epc_property WHERE source='lodged' +// epp = epc_property WHERE source='predicted' +// and take the legacy `property_details_epc` alias as a fragment so each query +// can keep its own (`e` vs `epc`). +// +// Add `newApproachJoins` to any query that uses one of the *Sql expressions. +// ───────────────────────────────────────────────────────────────────────────── + +type Alias = ReturnType; + +/** TRUE for new-approach rows. */ +export const isNewApproachSql = sql`p.updated_at >= ${NEW_APPROACH_CUTOFF}::date`; + +/** LEFT JOINs that expose the new sources alongside the legacy table. */ +export const newApproachJoins = sql` + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id + LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged' + LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted' +`; + +/** Baseline carbon (t CO₂/yr). New: effective performance. Legacy: e.co2_emissions. */ +export const carbonSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_co2_emissions_t_per_yr ELSE ${e}.co2_emissions END`; + +/** Primary energy intensity (kWh/m²/yr). New: effective performance. Legacy: e.primary_energy_consumption. */ +export const energyConsumptionSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_primary_energy_intensity_kwh_per_m2_yr ELSE ${e}.primary_energy_consumption END`; + +/** + * Annual energy bill (£). New: property_baseline_performance.total_annual_bill_gbp + * (NULL when the bill block hasn't been derived yet — excluded from AVG/SUM). + * Legacy: the old component-sum formula incl. standing charges and the + * installed-measures adjustment. + */ +export const billsSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN bp.total_annual_bill_gbp 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`; + +/** Total floor area (m²). New: lodged epc_property. Legacy: e.total_floor_area. */ +export const totalFloorAreaSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN epl.total_floor_area_m2 ELSE ${e}.total_floor_area END`; + +/** EPC lodgement date. New: lodged epc_property registration/inspection date. Legacy: e.lodgement_date. */ +export const lodgementDateSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN COALESCE(epl.registration_date, epl.inspection_date) ELSE ${e}.lodgement_date END`; + +/** + * "estimated" boolean. New: the property only has a predicted EPC (no lodged + * row). Legacy: e.estimated. + */ +export const estimatedSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND epp.id IS NOT NULL) ELSE COALESCE(${e}.estimated, false) END`; + +/** + * "expired" boolean. New: derived from the lodged EPC age (>10 years), since the + * new graph stores no is_expired flag. Legacy: e.is_expired. + */ +export const isExpiredSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN (epl.registration_date IS NOT NULL AND epl.registration_date < (CURRENT_DATE - INTERVAL '10 years')) ELSE ${e}.is_expired END`; + +/** Main fuel. GAP for new properties (no clean equivalent) → NULL. Legacy: e.mainfuel. */ +export const mainfuelSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN NULL ELSE ${e}.mainfuel END`; + +// ───────────────────────────────────────────────────────────────────────────── +// Per-property resolvers (TypeScript object building). +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The subset of the old `PropertyDetailsEpc` shape that the building-passport + * screens actually consume. Both the legacy row and the new graph map into this. + */ +export interface ConditionReport { + totalFloorArea: number | null; + // fabric descriptions + 1–5 ratings + walls: string | null; + wallsRating: number | null; + roof: string | null; + roofRating: number | null; + windows: string | null; + windowsRating: number | null; + heating: string | null; + heatingRating: number | null; + heatingControls: string | null; + heatingControlsRating: number | null; + hotWater: string | null; + hotWaterRating: number | null; + lighting: string | null; + lightingRating: number | null; + floor: string | null; + floorRating: number | null; + ventilation: string | null; + mainfuel: string | null; + solarPv: number | null; + solarHotWater: boolean | null; + windTurbine: number | null; + // general features + heatLossCorridor: boolean | null; + unheatedCorridorLength: number | null; + numberStoreys: number | null; + floorHeight: number | null; + numberHeatedRooms: number | null; + numberOpenFireplaces: number | null; + numberExtensions: number | null; + mainsGas: boolean | null; + energyTariff: string | null; + // heat demand + costs + currentEnergyDemand: number | null; + primaryEnergyConsumption: number | null; + co2Emissions: number | null; + heatingEnergyCostCurrent: number | null; + hotWaterEnergyCostCurrent: number | null; + lightingEnergyCostCurrent: number | null; + appliancesEnergyCostCurrent: number | null; + // estimated-data banner + estimated: boolean | null; +} + +/** Map an `epc_energy_element.energy_efficiency_rating` to a safe 1–5 (or null). */ +function safeRating(r: number | null | undefined): number | null { + return r != null && r >= 1 && r <= 5 ? r : null; +} + +/** + * Resolve a property's condition report from the new EPC graph. Prefers the + * lodged EPC; falls back to predicted (in which case `estimated = true`). + */ +async function resolveNewConditionReport( + propertyId: bigint, +): Promise { + const lodged = await db.query.epcProperty.findFirst({ + where: and( + eq(epcProperty.propertyId, propertyId), + eq(epcProperty.source, "lodged"), + ), + }); + const chosen = + lodged ?? + (await db.query.epcProperty.findFirst({ + where: and( + eq(epcProperty.propertyId, propertyId), + eq(epcProperty.source, "predicted"), + ), + })); + + if (!chosen) return null; + + const [elements, flat, baseline] = await Promise.all([ + db.query.epcEnergyElement.findMany({ + where: eq(epcEnergyElement.epcPropertyId, chosen.id), + }), + db.query.epcFlatDetails.findFirst({ + where: eq(epcFlatDetails.epcPropertyId, chosen.id), + }), + db.query.propertyBaselinePerformance.findFirst({ + where: eq(propertyBaselinePerformance.propertyId, propertyId), + }), + ]); + + const byType = (t: string) => elements.find((el) => el.elementType === t); + const desc = (t: string) => byType(t)?.description ?? null; + const rating = (t: string) => safeRating(byType(t)?.energyEfficiencyRating); + + const sumKwh = baseline + ? (baseline.heatingKwh ?? baseline.spaceHeatingKwh ?? 0) + + (baseline.hotWaterKwh ?? baseline.waterHeatingKwh ?? 0) + + (baseline.lightingKwh ?? 0) + + (baseline.appliancesKwh ?? 0) + + (baseline.cookingKwh ?? 0) + + (baseline.pumpsFansKwh ?? 0) + + (baseline.coolingKwh ?? 0) + : null; + + return { + totalFloorArea: chosen.totalFloorAreaM2 ?? null, + walls: desc("wall"), + wallsRating: rating("wall"), + roof: desc("roof"), + roofRating: rating("roof"), + windows: desc("window"), + windowsRating: rating("window"), + heating: desc("main_heating"), + heatingRating: rating("main_heating"), + heatingControls: desc("main_heating_controls"), + heatingControlsRating: rating("main_heating_controls"), + hotWater: desc("hot_water"), + hotWaterRating: rating("hot_water"), + lighting: desc("lighting"), + lightingRating: rating("lighting"), + floor: desc("floor"), + floorRating: rating("floor"), + ventilation: chosen.ventilationType ?? null, + // GAP: no clean main-fuel string in the new graph (jsonb on + // epc_main_heating_detail) — omitted for new properties. + mainfuel: null, + solarPv: chosen.photovoltaicArray ? 1 : 0, + solarHotWater: chosen.solarWaterHeating ?? null, + windTurbine: chosen.energyWindTurbinesCount ?? null, + heatLossCorridor: + flat?.heatLossCorridor != null ? flat.heatLossCorridor > 0 : null, + unheatedCorridorLength: flat?.unheatedCorridorLengthM ?? null, + numberStoreys: chosen.numberOfStoreys ?? null, + // GAP: no single dwelling floor height (only per-building-part) — omitted. + floorHeight: null, + numberHeatedRooms: chosen.heatedRoomsCount ?? null, + numberOpenFireplaces: chosen.openChimneysCount ?? null, + numberExtensions: chosen.extensionsCount ?? null, + mainsGas: chosen.energyMainsGas ?? null, + // GAP: no energy tariff in the new graph — omitted. + energyTariff: null, + currentEnergyDemand: sumKwh, + primaryEnergyConsumption: + baseline?.effectivePrimaryEnergyIntensityKwhPerM2Yr ?? null, + co2Emissions: baseline?.effectiveCo2EmissionsTPerYr ?? null, + heatingEnergyCostCurrent: baseline?.heatingCostGbp ?? null, + hotWaterEnergyCostCurrent: baseline?.hotWaterCostGbp ?? null, + lightingEnergyCostCurrent: baseline?.lightingCostGbp ?? null, + appliancesEnergyCostCurrent: baseline?.appliancesCostGbp ?? null, + estimated: !lodged, + }; +} + +/** Map a legacy `property_details_epc` row into the `ConditionReport` shape. */ +function legacyConditionReport(row: Record): ConditionReport { + const r = row as Partial; + return { + totalFloorArea: r.totalFloorArea ?? null, + walls: r.walls ?? null, + wallsRating: r.wallsRating ?? null, + roof: r.roof ?? null, + roofRating: r.roofRating ?? null, + windows: r.windows ?? null, + windowsRating: r.windowsRating ?? null, + heating: r.heating ?? null, + heatingRating: r.heatingRating ?? null, + heatingControls: r.heatingControls ?? null, + heatingControlsRating: r.heatingControlsRating ?? null, + hotWater: r.hotWater ?? null, + hotWaterRating: r.hotWaterRating ?? null, + lighting: r.lighting ?? null, + lightingRating: r.lightingRating ?? null, + floor: r.floor ?? null, + floorRating: r.floorRating ?? null, + ventilation: r.ventilation ?? null, + mainfuel: r.mainfuel ?? null, + solarPv: r.solarPv ?? null, + solarHotWater: r.solarHotWater ?? null, + windTurbine: r.windTurbine ?? null, + heatLossCorridor: r.heatLossCorridor ?? null, + unheatedCorridorLength: r.unheatedCorridorLength ?? null, + numberStoreys: r.numberStoreys ?? null, + floorHeight: r.floorHeight ?? null, + numberHeatedRooms: r.numberHeatedRooms ?? null, + numberOpenFireplaces: r.numberOpenFireplaces ?? null, + numberExtensions: r.numberExtensions ?? null, + mainsGas: r.mainsGas ?? null, + energyTariff: r.energyTariff ?? null, + currentEnergyDemand: r.currentEnergyDemand ?? null, + primaryEnergyConsumption: r.primaryEnergyConsumption ?? null, + co2Emissions: r.co2Emissions ?? null, + heatingEnergyCostCurrent: r.heatingEnergyCostCurrent ?? null, + hotWaterEnergyCostCurrent: r.hotWaterEnergyCostCurrent ?? null, + lightingEnergyCostCurrent: r.lightingEnergyCostCurrent ?? null, + appliancesEnergyCostCurrent: r.appliancesEnergyCostCurrent ?? null, + estimated: r.estimated ?? null, + }; +} + +/** + * Resolve a property's condition report, branching on the cutoff. New-approach + * properties never touch `property_details_epc`. + */ +export async function resolveConditionReport( + propertyId: string, +): Promise { + const id = BigInt(propertyId); + const prop = await db.query.property.findFirst({ + columns: { updatedAt: true }, + where: eq(property.id, id), + }); + + if (prop && isNewApproach(prop.updatedAt)) { + const resolved = await resolveNewConditionReport(id); + if (!resolved) { + throw new Error( + `No epc_property graph found for new-approach property ${propertyId}`, + ); + } + return resolved; + } + + const legacy = await db.query.propertyDetailsEpc.findFirst({ + where: eq(propertyDetailsEpc.propertyId, id), + }); + if (!legacy) { + throw new Error(`No property_details_epc row for property ${propertyId}`); + } + return legacyConditionReport(legacy as Record); +} + +/** The minimal EPC meta the `/api/property-meta` route embeds as `detailsEpc`. */ +export interface DetailsEpcMeta { + currentEnergyDemand: number | null; + co2Emissions: number | null; + estimated: boolean; +} + +/** Resolve the `detailsEpc` meta block, branching on the cutoff. */ +export async function resolveDetailsEpcMeta( + propertyId: bigint, + updatedAt: Date | string | null | undefined, +): Promise { + if (isNewApproach(updatedAt)) { + const [lodged, predicted, baseline] = await Promise.all([ + db.query.epcProperty.findFirst({ + columns: { id: true }, + where: and( + eq(epcProperty.propertyId, propertyId), + eq(epcProperty.source, "lodged"), + ), + }), + db.query.epcProperty.findFirst({ + columns: { id: true }, + where: and( + eq(epcProperty.propertyId, propertyId), + eq(epcProperty.source, "predicted"), + ), + }), + db.query.propertyBaselinePerformance.findFirst({ + where: eq(propertyBaselinePerformance.propertyId, propertyId), + }), + ]); + + const currentEnergyDemand = baseline + ? (baseline.heatingKwh ?? baseline.spaceHeatingKwh ?? 0) + + (baseline.hotWaterKwh ?? baseline.waterHeatingKwh ?? 0) + + (baseline.lightingKwh ?? 0) + + (baseline.appliancesKwh ?? 0) + : null; + + return { + currentEnergyDemand, + co2Emissions: baseline?.effectiveCo2EmissionsTPerYr ?? null, + estimated: !lodged && !!predicted, + }; + } + + const legacy = await db.query.propertyDetailsEpc.findFirst({ + columns: { currentEnergyDemand: true, co2Emissions: true, estimated: true }, + where: eq(propertyDetailsEpc.propertyId, propertyId), + }); + return { + currentEnergyDemand: legacy?.currentEnergyDemand ?? null, + co2Emissions: legacy?.co2Emissions ?? null, + estimated: legacy?.estimated ?? false, + }; +}