diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index c2533cea..89875a9b 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -316,26 +316,27 @@ export function formatGeneralFeatures( }, { feature: "Number of storeys", - description: conditionReportData.numberStoreys || "Unknown", + description: conditionReportData.numberStoreys ?? "Unknown", }, ]; const generealFeatures: GeneralFeature[] = [ { feature: "Floor Height", - description: conditionReportData.floorHeight || "Unknown", + description: conditionReportData.floorHeight ?? "Unknown", }, { feature: "Number of heated rooms", - description: conditionReportData.numberHeatedRooms || "Unknown", + description: conditionReportData.numberHeatedRooms ?? "Unknown", }, { feature: "Number of open fire places", - description: conditionReportData.numberOpenFireplaces || "Unknown", + // ?? so a genuine 0 (no fireplaces) shows as 0, not "Unknown". + description: conditionReportData.numberOpenFireplaces ?? "Unknown", }, { feature: "Number of extensions", - description: conditionReportData.numberExtensions || "Unknown", + description: conditionReportData.numberExtensions ?? "Unknown", }, { feature: "Mains gas", diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index 0b376957..e8071093 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -33,6 +33,8 @@ import { epcProperty, epcEnergyElement, epcFlatDetails, + epcBuildingPart, + epcFloorDimension, propertyBaselinePerformance, propertyDetailsEpc, property, @@ -235,7 +237,7 @@ async function resolveNewConditionReport( if (!chosen) return null; - const [elements, flat, baseline] = await Promise.all([ + const [elements, flat, baseline, buildingParts] = await Promise.all([ db.query.epcEnergyElement.findMany({ where: eq(epcEnergyElement.epcPropertyId, chosen.id), }), @@ -245,8 +247,22 @@ async function resolveNewConditionReport( db.query.propertyBaselinePerformance.findFirst({ where: eq(propertyBaselinePerformance.propertyId, propertyId), }), + db.query.epcBuildingPart.findMany({ + columns: { id: true, identifier: true }, + where: eq(epcBuildingPart.epcPropertyId, chosen.id), + }), ]); + // Floor height: room height of the main building part (fall back to any part). + const mainPart = + buildingParts.find((b) => b.identifier === "main") ?? buildingParts[0]; + const mainFloor = mainPart + ? await db.query.epcFloorDimension.findFirst({ + columns: { roomHeightM: true }, + where: eq(epcFloorDimension.epcBuildingPartId, mainPart.id), + }) + : null; + 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); @@ -290,8 +306,9 @@ async function resolveNewConditionReport( 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, + // Room height of the main building part (the new graph stores height per + // building part rather than one dwelling value). + floorHeight: mainFloor?.roomHeightM ?? null, numberHeatedRooms: chosen.heatedRoomsCount ?? null, numberOpenFireplaces: chosen.openChimneysCount ?? null, numberExtensions: chosen.extensionsCount ?? null, @@ -465,6 +482,90 @@ export async function resolveDetailsEpcMeta( }; } +// RdSAP code → label maps for the descriptive fields the new pipeline stores as +// codes on epc_property / epc_building_part (the property-row text columns aren't +// written for new-approach properties). Unknown codes fall back to the raw value. +const PROPERTY_TYPE_LABELS: Record = { + "0": "House", + "1": "Bungalow", + "2": "Flat", + "3": "Maisonette", + "4": "Park home", +}; +const BUILT_FORM_LABELS: Record = { + "1": "Detached", + "2": "Semi-Detached", + "3": "End-Terrace", + "4": "Mid-Terrace", + "5": "Enclosed End-Terrace", + "6": "Enclosed Mid-Terrace", +}; +const AGE_BAND_YEARS: Record = { + A: "before 1900", + B: "1900–1929", + C: "1930–1949", + D: "1950–1966", + E: "1967–1975", + F: "1976–1982", + G: "1983–1990", + H: "1991–1995", + I: "1996–2002", + J: "2003–2006", + K: "2007–2011", + L: "2012 onwards", +}; + +/** + * Resolve the descriptive property fields (type / built form / age) from the + * epc_property graph for new-approach properties, since the property-row text + * columns aren't written. Returns nulls when there's no EPC graph. + */ +async function resolvePropertyDescriptors( + propertyId: bigint, +): Promise<{ + propertyType: string | null; + builtForm: string | null; + yearBuilt: string | null; +}> { + const ep = + (await db.query.epcProperty.findFirst({ + columns: { id: true, propertyType: true, builtForm: true, dwellingType: true }, + where: and( + eq(epcProperty.propertyId, propertyId), + eq(epcProperty.source, "lodged"), + ), + })) ?? + (await db.query.epcProperty.findFirst({ + columns: { id: true, propertyType: true, builtForm: true, dwellingType: true }, + where: and( + eq(epcProperty.propertyId, propertyId), + eq(epcProperty.source, "predicted"), + ), + })); + + if (!ep) { + return { propertyType: null, builtForm: null, yearBuilt: null }; + } + + const mainPart = await db.query.epcBuildingPart.findFirst({ + columns: { constructionAgeBand: true }, + where: eq(epcBuildingPart.epcPropertyId, ep.id), + }); + + const propertyType = + (ep.propertyType ? PROPERTY_TYPE_LABELS[ep.propertyType] : null) ?? + ep.dwellingType ?? + ep.propertyType ?? + null; + const builtForm = ep.builtForm + ? BUILT_FORM_LABELS[ep.builtForm] ?? ep.builtForm + : null; + const ageBand = mainPart?.constructionAgeBand ?? null; + const yearBuilt = ageBand ? AGE_BAND_YEARS[ageBand] ?? ageBand : null; + + return { propertyType, builtForm, yearBuilt }; +} + /** * Resolve the full property-meta object (the property row + branched * `detailsEpc` + headline SAP/EPC). Shared by the `/api/property-meta` route and @@ -501,13 +602,20 @@ export async function resolvePropertyMeta(propertyId: string) { if (!propertyMeta) return null; - const [detailsEpc, headline] = await Promise.all([ + const newApproach = isNewApproach(propertyMeta.updatedAt); + const [detailsEpc, headline, descriptors] = await Promise.all([ resolveDetailsEpcMeta(propertyMeta.id, propertyMeta.updatedAt), resolvePropertyHeadline(propertyMeta.id, propertyMeta.updatedAt), + newApproach + ? resolvePropertyDescriptors(propertyMeta.id) + : Promise.resolve(null), ]); return { ...propertyMeta, + propertyType: descriptors?.propertyType ?? propertyMeta.propertyType, + builtForm: descriptors?.builtForm ?? propertyMeta.builtForm, + yearBuilt: descriptors?.yearBuilt ?? propertyMeta.yearBuilt, currentSapPoints: headline.currentSapPoints ?? propertyMeta.currentSapPoints, currentEpcRating: