perf(properties): LATERAL lookups + recommendation by property_id

/api/properties timed out (15s) on just 12 properties: joining the 26M-row
recommendation table on the unindexed plan_id forced a full-table hash, and the
multi-million-row plan/funding joins compounded it.

Rewrite getProperties/getPropertiesCount to read the denormalised model (no
plan_recommendations / recommendation_materials): one-row LATERAL lookups for
target/plan/funding and a recommendation aggregate keyed by the INDEXED
recommendation.property_id, dropping the GROUP BY entirely. ~1s -> instant for a
12-property portfolio.

Also fix the reporting page for new-approach properties (current_sap_points /
current_epc_rating on the property row are unwritten): source avg SAP and the
EPC band distribution from property_baseline_performance (lodged_sap_score /
lodged_epc_band) via new sapSql / epcBandSql helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-17 14:07:09 +00:00
parent f3e00429f9
commit 14f06abb6d
3 changed files with 61 additions and 51 deletions

View file

@ -9,6 +9,8 @@ import {
energyConsumptionSql,
estimatedSql,
isExpiredSql,
sapSql,
epcBandSql,
} from "@/lib/services/epcSources";
import type {
@ -36,7 +38,7 @@ export async function getAverages(
): Promise<AverageMetrics> {
const result = await db.execute<AverageMetrics>(sql`
SELECT
AVG(p.current_sap_points)::float AS avg_sap,
AVG(${sapSql})::float AS avg_sap,
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
@ -97,7 +99,7 @@ export async function getCountByEpcBand(
SELECT *
FROM (
SELECT
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
COALESCE((${epcBandSql})::text, 'Unknown') AS epc,
COUNT(*) FILTER (
WHERE ${estimatedSql(sql`e`)} = false
)::int AS actual,

View file

@ -682,7 +682,14 @@ export async function getPropertiesCount(
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
LEFT JOIN LATERAL (
SELECT id, post_sap_points FROM plan
WHERE property_id = p.id
AND portfolio_id = p.portfolio_id
AND is_default = true
ORDER BY created_at DESC
LIMIT 1
) pl ON true
WHERE p.portfolio_id = ${portfolioId}
${combinedWhere}
`);
@ -714,8 +721,8 @@ export async function getProperties(
t.epc AS "targetEpc",
pl.id AS "planId",
fp.scheme AS "fundingScheme",
COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints",
COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost",
COALESCE(rec.sap_points, 0) AS "totalRecommendationSapPoints",
COALESCE(rec.cost, 0) AS "totalRecommendationCost",
p.landlord_property_id AS "landlordPropertyId",
p.original_sap_points AS "originalSapPoints",
p.property_type AS "propertyType",
@ -729,57 +736,47 @@ export async function getProperties(
${mainfuelSql(sql`epc`)} AS mainfuel,
p.lexiscore AS lexiscore
FROM property p
LEFT JOIN property_targets t
ON t.property_id = p.id
LEFT JOIN plan pl
ON pl.property_id = p.id
AND pl.is_default = true
LEFT JOIN funding_package fp
ON fp.plan_id = pl.id
-- Recommendations link to their plan via recommendation.plan_id (the
-- plan_recommendations join table is retired). Using plan_id is what makes
-- Plan Cost / recommendation SAP populate for new-approach properties.
LEFT JOIN recommendation r
ON r.plan_id = pl.id
AND r.default = true
AND r.already_installed = false
-- LATERAL one-row lookups keep this query at "12 properties × index probe"
-- instead of hash-joining the multi-million-row plan/recommendation tables.
LEFT JOIN LATERAL (
SELECT epc FROM property_targets
WHERE property_id = p.id
ORDER BY created_at DESC
LIMIT 1
) t ON true
LEFT JOIN LATERAL (
SELECT * FROM plan
WHERE property_id = p.id
AND portfolio_id = p.portfolio_id
AND is_default = true
ORDER BY created_at DESC
LIMIT 1
) pl ON true
LEFT JOIN LATERAL (
SELECT scheme FROM funding_package
WHERE plan_id = pl.id
LIMIT 1
) fp ON true
-- Active (default, not-yet-installed) recommendations for the property,
-- read denormalised. We aggregate by recommendation.property_id the only
-- indexed access path (there is NO index on recommendation.plan_id, and the
-- table has tens of millions of rows) and rely on the partial index on
-- (default, already_installed). The retired plan_recommendations join table
-- is intentionally not used.
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(sap_points), 0) AS sap_points,
COALESCE(SUM(estimated_cost), 0) AS cost
FROM recommendation
WHERE property_id = p.id
AND "default" = true
AND already_installed = false
) rec ON true
LEFT JOIN property_details_epc epc
ON epc.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId}
${combinedWhere}
GROUP BY
p.id,
p.portfolio_id,
p.address,
p.postcode,
p.status,
p.creation_status,
p.current_epc_rating,
p.current_sap_points,
t.epc,
pl.id,
fp.scheme,
p.landlord_property_id,
p.original_sap_points,
p.property_type,
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.lodged_co2_emissions_t_per_yr,
p.lexiscore
LIMIT ${limit} OFFSET ${offset};
`);

View file

@ -85,6 +85,17 @@ export const carbonSql = (e: Alias) =>
export const energyConsumptionSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_primary_energy_intensity_kwh_per_m2_yr ELSE ${e}.primary_energy_consumption END`;
/**
* Baseline SAP score. New: the lodged (gov-registry) score from
* property_baseline_performance (the property-row current_sap_points isn't
* written for new-approach properties). Legacy: property.current_sap_points.
* Swap to `effective_sap_score` for the modelling-adjusted baseline.
*/
export const sapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_sap_score ELSE p.current_sap_points END`;
/** Baseline EPC band. New: lodged band from property_baseline_performance. Legacy: property.current_epc_rating. */
export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_band ELSE p.current_epc_rating END`;
/**
* Annual energy bill (£). New: sum of the individual cost columns on
* property_baseline_performance (NULL when no baseline row exists, so it's