diff --git a/src/app/api/postcode/FormatOsResults.ts b/src/app/api/postcode/FormatOsResults.ts new file mode 100644 index 0000000..dc68c8e --- /dev/null +++ b/src/app/api/postcode/FormatOsResults.ts @@ -0,0 +1,18 @@ +/** + * Extracts simplified address list from OS Places response. + */ +export function formatOsResults(apiResponse: any) { + if (!apiResponse?.results) return []; + + return apiResponse.results + .map((entry: any) => { + const dpa = entry.DPA; + if (!dpa) return null; + + return { + uprn: dpa.UPRN, + address: dpa.ADDRESS, + }; + }) + .filter(Boolean); +} diff --git a/src/app/api/postcode/LookupOsPlaces.ts b/src/app/api/postcode/LookupOsPlaces.ts new file mode 100644 index 0000000..1bdf590 --- /dev/null +++ b/src/app/api/postcode/LookupOsPlaces.ts @@ -0,0 +1,148 @@ +import { OSPlacesHeader, OSPlacesResponse } from "@/app/db/schema/addresses"; + +const OS_API_URL = "https://api.os.uk/search/places/v1/postcode"; +const OS_API_KEY = process.env.OS_API_KEY!; + +type OSPlacesItem = { DPA?: Record; LPI?: Record }; + +export interface OSPlacesResult { + status: number; + // First page response (kept for completeness) + data?: OSPlacesResponse; + // All results across pages (flattened) + results?: OSPlacesItem[]; + error?: string; +} + +/** + * Fetch a single page from OS Places + */ +async function fetchOsPlacesPage( + postcode: string, + offset: number, + maxResults: number, + retries: number, + delay: number +): Promise<{ status: number; data?: OSPlacesResponse; error?: string }> { + const url = `${OS_API_URL}?postcode=${encodeURIComponent( + postcode + )}&key=${OS_API_KEY}&maxresults=${maxResults}&offset=${offset}`; + + try { + const res = await fetch(url, { method: "GET" }); + + if (res.ok) { + const data: OSPlacesResponse = await res.json(); + return { status: res.status, data }; + } + + // Retry on transient issues (rate limit, server errors) + if ([429, 500, 503].includes(res.status) && retries > 0) { + await new Promise((r) => setTimeout(r, delay)); + return fetchOsPlacesPage( + postcode, + offset, + maxResults, + retries - 1, + delay * 2 + ); + } + + // Map known errors + let errorMessage = "Unexpected error"; + switch (res.status) { + case 400: + errorMessage = "Bad request — malformed postcode or query"; + break; + case 401: + errorMessage = "Unauthorized — check OS API key"; + break; + case 403: + errorMessage = "Forbidden — insufficient permissions for API access"; + break; + case 404: + errorMessage = "Postcode not found"; + break; + case 405: + errorMessage = "Method not allowed"; + break; + case 429: + errorMessage = "Rate limit exceeded — too many requests"; + break; + case 503: + errorMessage = "Service temporarily unavailable"; + break; + default: + errorMessage = `Unhandled error (${res.status})`; + } + return { status: res.status, error: errorMessage }; + } catch (err: any) { + if (retries > 0) { + await new Promise((r) => setTimeout(r, delay)); + return fetchOsPlacesPage( + postcode, + offset, + maxResults, + retries - 1, + delay * 2 + ); + } + return { status: 500, error: err?.message || "Network error" }; + } +} + +/** + * Calls the Ordnance Survey Places API for a given postcode, + * with offset-based pagination, retry handling, and structured errors. + */ +export async function lookupOsPlaces( + postcode: string, + { + maxResults = 100, + retries = 2, + delay = 1000, + }: { maxResults?: number; retries?: number; delay?: number } = {} +): Promise { + const normalized = postcode.toUpperCase().trim(); + + // Page 1 + const first = await fetchOsPlacesPage( + normalized, + 0, + maxResults, + retries, + delay + ); + if (first.error || !first.data) { + return { status: first.status, error: first.error }; + } + + const total = + first.data.header?.totalresults ?? first.data.results?.length ?? 0; + let allResults: OSPlacesItem[] = first.data.results ?? []; + + // If more pages exist, fetch them in a loop + for (let offset = maxResults; offset < total; offset += maxResults) { + const page = await fetchOsPlacesPage( + normalized, + offset, + maxResults, + retries, + delay + ); + if (page.error || !page.data) { + // Return what we have so far but indicate partial failure + return { + status: page.status, + data: first.data, + results: allResults, + error: `Failed to fetch page at offset ${offset}: ${page.error ?? "Unknown error"}`, + }; + } + if (page.data.results?.length) { + allResults = allResults.concat(page.data.results); + } + } + + return { status: 200, data: first.data, results: allResults }; +} diff --git a/src/app/api/postcode/LookupPostcode.ts b/src/app/api/postcode/LookupPostcode.ts new file mode 100644 index 0000000..c9376f4 --- /dev/null +++ b/src/app/api/postcode/LookupPostcode.ts @@ -0,0 +1,59 @@ +// src/app/utils/postcodes.ts + +export interface PostcodeLookupResult { + status: number; + result?: { + postcode: string; + country: string; + region: string | null; + admin_district: string | null; + latitude: number; + longitude: number; + }; + error?: string; +} + +/** + * Look up a postcode using postcodes.io. + * Includes automatic retry logic for transient 5xx errors. + */ +export async function lookupPostcode( + postcode: string, + retries = 2 +): Promise { + const url = `https://api.postcodes.io/postcodes/${encodeURIComponent( + postcode.trim() + )}`; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const res = await fetch(url); + + if (!res.ok) { + const data = await res.json(); + // Retry only on transient 5xx errors + if (res.status >= 500 && attempt < retries) { + console.warn(`Retrying postcode lookup (attempt ${attempt + 1})`); + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + continue; + } + return data; + } + + return await res.json(); + } catch (error) { + if (attempt < retries) { + console.warn(`Network error on attempt ${attempt + 1}, retrying...`); + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } else { + return { + status: 500, + error: "Network error while contacting postcodes.io", + }; + } + } + } + + // Should never reach here + return { status: 500, error: "Unexpected error" }; +} diff --git a/src/app/api/postcode/OsPlacesToFlat.ts b/src/app/api/postcode/OsPlacesToFlat.ts new file mode 100644 index 0000000..d750f7e --- /dev/null +++ b/src/app/api/postcode/OsPlacesToFlat.ts @@ -0,0 +1,99 @@ +export type FlatAddress = { + uprn: string; + address: string; + propertyType?: PropertyType; + builtForm?: BuiltForm; +}; + +export type PropertyType = "House" | "Flat" | "Maisonette" | "Bungalow"; +export type BuiltForm = + | "Detached" + | "Semi-Detached" + | "Mid-Terrace" + | "End-Terrace" + | "Enclosed End-Terrace" + | "Enclosed Mid-Terrace"; + +export function mapOsClassToProperty(classification?: string): { + propertyType?: PropertyType; + builtForm?: BuiltForm; +} { + if (!classification) return {}; + + const code = classification.toUpperCase(); + + if (code.startsWith("RD02")) { + return { propertyType: "House", builtForm: "Detached" }; + } + if (code.startsWith("RD03")) { + return { propertyType: "House", builtForm: "Semi-Detached" }; + } + if (code.startsWith("RD04")) { + return { propertyType: "House", builtForm: "Mid-Terrace" }; + } + if (code.startsWith("RD06")) { + return { propertyType: "Flat" }; + } + + return {}; +} + +export function inferPropertyFromAddress(address: string): { + propertyType?: PropertyType; + builtForm?: BuiltForm; +} { + const addr = address.toLowerCase(); + + // Detect explicit mentions + if (addr.includes("bungalow")) return { propertyType: "Bungalow" }; + if (addr.includes("maisonette")) return { propertyType: "Maisonette" }; + if (addr.includes("flat") || addr.includes("apartment")) + return { propertyType: "Flat" }; + + // If it says "terrace" in address, but no RD code, assume terraced house + if (addr.includes("terrace") || addr.includes("terraced")) + return { propertyType: "House", builtForm: "Mid-Terrace" }; + + // Minimal fallback if text only shows 'house' or street number + if (addr.match(/\d+\s+[a-z]/)) return { propertyType: "House" }; + + return {}; +} + +export function mapOsPlacesToFlat( + results?: Array<{ DPA?: any; LPI?: any }> +): FlatAddress[] { + if (!results?.length) return []; + + const items = results + .map((r) => r.DPA ?? r.LPI) + .filter(Boolean) + .map((rec: any) => { + const classification = rec.CLASSIFICATION_CODE as string | undefined; + const base = mapOsClassToProperty(classification); + const textBased = inferPropertyFromAddress(String(rec.ADDRESS ?? "")); + + // Merge — classification has priority, but text fills in missing gaps + const propertyType = base.propertyType ?? textBased.propertyType; + const builtForm = base.builtForm ?? textBased.builtForm; + + return { + uprn: String(rec.UPRN ?? ""), + address: String(rec.ADDRESS ?? ""), + propertyType, + builtForm, + }; + }) + .filter((x) => x.uprn && x.address); + + // De-duplicate by UPRN + const seen = new Set(); + const deduped: FlatAddress[] = []; + for (const it of items) { + if (!seen.has(it.uprn)) { + seen.add(it.uprn); + deduped.push(it); + } + } + return deduped; +} diff --git a/src/app/api/postcode/[postcode]/addresses/route.ts b/src/app/api/postcode/[postcode]/addresses/route.ts new file mode 100644 index 0000000..0a4c9c6 --- /dev/null +++ b/src/app/api/postcode/[postcode]/addresses/route.ts @@ -0,0 +1,75 @@ +import { NextResponse, NextRequest } from "next/server"; +import { db } from "@/app/db/db"; +import { postcodeSearch } from "@/app/db/schema/addresses"; +import { eq } from "drizzle-orm"; +import { lookupOsPlaces } from "@/app/api/postcode/LookupOsPlaces"; +import { mapOsPlacesToFlat } from "@/app/api/postcode/OsPlacesToFlat"; + +export async function GET( + request: NextRequest, + props: { params: Promise<{ postcode: string }> } +) { + const { postcode } = await props.params; + if (!postcode || typeof postcode !== "string") { + return NextResponse.json( + { error: "Missing or invalid postcode" }, + { status: 400 } + ); + } + + try { + // Step 1: check cache + const cached = await db + .select() + .from(postcodeSearch) + .where(eq(postcodeSearch.postcode, postcode)) + .limit(1); + + if (cached.length > 0) { + console.log("Using cached OS Places data for postcode:", postcode); + const record = cached[0]; + const addresses = mapOsPlacesToFlat(record.resultData?.results); + return NextResponse.json({ + status: 200, + source: "cache", + total: record.resultData?.header?.totalresults ?? addresses.length, + results: addresses, + }); + } + + // Step 2: if no cache, query OS Places API + const result = await lookupOsPlaces(postcode); + if (result.error || result.status !== 200 || !result.data) { + return NextResponse.json( + { + error: result.error ?? "Failed to fetch address data", + status: result.status, + }, + { status: result.status } + ); + } + + // Step 3: flatten and cache the result + const addresses = mapOsPlacesToFlat(result.results); + const total = result.data.header?.totalresults ?? addresses.length; + + await db.insert(postcodeSearch).values({ + postcode: postcode, + resultData: result.data, + }); + + // Step 4: return results + return NextResponse.json({ + status: 200, + source: "live", + total, + results: addresses, + }); + } catch (err: any) { + console.error("Error fetching OS Places data:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/postcode/[postcode]/route.ts b/src/app/api/postcode/[postcode]/route.ts new file mode 100644 index 0000000..925c9f0 --- /dev/null +++ b/src/app/api/postcode/[postcode]/route.ts @@ -0,0 +1,31 @@ +import { NextResponse, NextRequest } from "next/server"; +import { lookupPostcode } from "../LookupPostcode"; + +export async function GET( + request: NextRequest, + props: { params: Promise<{ postcode: string }> } +) { + const { postcode } = await props.params; + + if (!postcode || typeof postcode !== "string") { + return NextResponse.json( + { error: "Missing or invalid postcode" }, + { status: 400 } + ); + } + + const data = await lookupPostcode(postcode); + + if (data.status === 404) { + return NextResponse.json({ error: "Invalid postcode" }, { status: 404 }); + } + + if (data.status !== 200 || !data.result) { + return NextResponse.json( + { error: data.error || "Postcode lookup failed" }, + { status: data.status || 500 } + ); + } + + return NextResponse.json({ result: data.result }); +} diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 9bb4958..20aa844 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -27,7 +27,7 @@ import { PropertyMeta } from "@/app/db/schema/property"; interface ToolbarProps { propertyId: string; portfolioId: string; - propertyMeta: PropertyMeta + propertyMeta: PropertyMeta; decentHomes: getUploadedFile; } @@ -172,7 +172,7 @@ export function Toolbar({ setShowToast(true)} diff --git a/src/app/components/portfolio/AddNew.tsx b/src/app/components/portfolio/AddNew.tsx index c1e5b5e..55b0888 100644 --- a/src/app/components/portfolio/AddNew.tsx +++ b/src/app/components/portfolio/AddNew.tsx @@ -5,8 +5,8 @@ import { NavigationMenuLink, NavigationMenuTrigger, } from "@/app/shadcn_components/ui/navigation-menu"; +import { useRouter } from "next/navigation"; import { - PlusIcon, TableCellsIcon, DocumentMagnifyingGlassIcon, } from "@heroicons/react/24/outline"; @@ -41,32 +41,32 @@ const ListItem = React.forwardRef< ListItem.displayName = "ListItem"; export default function AddNewDropDown({ + portfolioId, isUploadCsvOpen, setIsUploadCsvOpen, isRemoteAssessmentOpen, setIsRemoteAssessmentOpen, }: { + portfolioId: string; isUploadCsvOpen: boolean; setIsUploadCsvOpen: React.Dispatch>; isRemoteAssessmentOpen: boolean; setIsRemoteAssessmentOpen: React.Dispatch>; }) { - function handleCickAddUnit() { - console.log("Add unit"); - } - function handleClickUploadCSV() { setIsUploadCsvOpen(!isUploadCsvOpen); } + const router = useRouter(); + function handleClickRemoteAssessment() { - setIsRemoteAssessmentOpen(!isRemoteAssessmentOpen); + router.push(`/portfolio/${portfolioId}/remote-assessment`); } return ( - Add New + New Property
    diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index cc50eb1..5d57c6e 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -14,7 +14,6 @@ import { import AddNewDropDown from "./AddNew"; import { cva } from "class-variance-authority"; import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal"; -import RemoteAssessmentModal from "@/app/portfolio/[slug]/components/RemoteAssessmentModal"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { ScenarioSelect } from "@/app/db/schema/recommendations"; @@ -97,18 +96,13 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { - ; + LPI?: Record; +} + +export interface OSPlacesResponse { + header?: OSPlacesHeader; + results?: OSPlacesItem[]; +} + +export const postcodeSearch = pgTable("postcode_search", { + id: serial("id").primaryKey(), + + // Normalized postcode (uppercase, no spaces) + postcode: text("postcode").notNull().unique(), + + // Full OS Places API response + resultData: jsonb("result_data").$type().notNull(), + + // Timestamp for when the entry was first created + createdAt: timestamp("created_at").defaultNow().notNull(), +}); diff --git a/src/app/portfolio/[slug]/components/FormSchema.tsx b/src/app/portfolio/[slug]/components/FormSchema.tsx index 1a2c36f..197215c 100644 --- a/src/app/portfolio/[slug]/components/FormSchema.tsx +++ b/src/app/portfolio/[slug]/components/FormSchema.tsx @@ -19,9 +19,9 @@ export const RemoteAssessmentFormSchema = baseFormSchema addressLineOne: z.string().min(1), postcode: z.string().min(1), uprn: z.number().min(1, "UPRN must be a valid number"), - valuation: z.number().min(1, "Valuation must be a valid number"), - propertyType: z.string().nullable(), - builtForm: z.string().nullable(), + valuation: z.number().min(1, "Valuation must be a valid number").optional(), + propertyType: z.string().nullable().optional(), + builtForm: z.string().nullable().optional(), }) .refine((data) => data.goal !== "Increasing EPC" || !!data.goalValue, { path: ["goalValue"], @@ -50,11 +50,14 @@ export const uploadCsvSchema = baseFormSchema.extend({ if (val === "" || val === undefined) return undefined; return Number(val); }, z.number().min(0.1)), - budget: z.preprocess((val) => { - if (val === "" || val === undefined) return undefined; - if (val === null) return null; - return Number(val); - }, z.union([z.number(), z.null()]).optional()), + budget: z.preprocess( + (val) => { + if (val === "" || val === undefined) return undefined; + if (val === null) return null; + return Number(val); + }, + z.union([z.number(), z.null()]).optional() + ), }); export type UploadCsvFormValues = z.infer; diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentDropdowns.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentDropdowns.tsx index 706e272..07f65c1 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentDropdowns.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentDropdowns.tsx @@ -46,7 +46,7 @@ export function SelectScenarioDropdown({ {selectedValue === newOption.value && (