mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
missing files
This commit is contained in:
parent
a26cdb3d14
commit
f481103b5e
7 changed files with 4200 additions and 0 deletions
18
src/app/api/postcode/FormatOsResults.ts
Normal file
18
src/app/api/postcode/FormatOsResults.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
148
src/app/api/postcode/LookupOsPlaces.ts
Normal file
148
src/app/api/postcode/LookupOsPlaces.ts
Normal file
|
|
@ -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<string, any>; LPI?: Record<string, any> };
|
||||
|
||||
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<OSPlacesResult> {
|
||||
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 };
|
||||
}
|
||||
28
src/app/api/postcode/OsPlacesToFlat.ts
Normal file
28
src/app/api/postcode/OsPlacesToFlat.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// lib/osPlaces/mapper.ts
|
||||
export type FlatAddress = { uprn: string; address: string };
|
||||
|
||||
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) => ({
|
||||
uprn: String(rec.UPRN ?? ""),
|
||||
address: String(rec.ADDRESS ?? ""),
|
||||
}))
|
||||
.filter((x) => x.uprn && x.address);
|
||||
|
||||
// de-dupe by UPRN (prefers first occurrence)
|
||||
const seen = new Set<string>();
|
||||
const deduped: FlatAddress[] = [];
|
||||
for (const it of items) {
|
||||
if (!seen.has(it.uprn)) {
|
||||
seen.add(it.uprn);
|
||||
deduped.push(it);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
83
src/app/api/postcode/[postcode]/addresses/route.ts
Normal file
83
src/app/api/postcode/[postcode]/addresses/route.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { NextResponse } 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";
|
||||
|
||||
// Utility to normalize the postcode format
|
||||
function normalizePostcode(postcode: string) {
|
||||
return postcode.toUpperCase().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: { postcode: string } }
|
||||
) {
|
||||
const { postcode } = await params;
|
||||
if (!postcode || typeof postcode !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or invalid postcode" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = normalizePostcode(postcode);
|
||||
|
||||
try {
|
||||
// Step 1: check cache
|
||||
const cached = await db
|
||||
.select()
|
||||
.from(postcodeSearch)
|
||||
.where(eq(postcodeSearch.postcode, normalized))
|
||||
.limit(1);
|
||||
|
||||
if (cached.length > 0) {
|
||||
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(normalized);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Fetched OS Places data:", result);
|
||||
|
||||
// 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: normalized,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/app/db/migrations/0120_watery_captain_america.sql
Normal file
7
src/app/db/migrations/0120_watery_captain_america.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE "postcode_search" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"postcode" text NOT NULL,
|
||||
"result_data" jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "postcode_search_postcode_unique" UNIQUE("postcode")
|
||||
);
|
||||
3881
src/app/db/migrations/meta/0120_snapshot.json
Normal file
3881
src/app/db/migrations/meta/0120_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
35
src/app/db/schema/addresses.ts
Normal file
35
src/app/db/schema/addresses.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { pgTable, serial, text, jsonb, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
// This table stores postcode search results from the OS Places API
|
||||
// for re-use and caching purposes. The data is stored in jsonb format, to
|
||||
// allow for fast queries and flexibility with the API response structure.
|
||||
|
||||
export interface OSPlacesHeader {
|
||||
totalresults?: number;
|
||||
offset?: number;
|
||||
maxresults?: number;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export interface OSPlacesItem {
|
||||
DPA?: Record<string, any>;
|
||||
LPI?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<OSPlacesResponse>().notNull(),
|
||||
|
||||
// Timestamp for when the entry was first created
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue