missing files

This commit is contained in:
Khalim Conn-Kowlessar 2025-10-23 11:49:04 +01:00
parent a26cdb3d14
commit f481103b5e
7 changed files with 4200 additions and 0 deletions

View 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);
}

View 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 };
}

View 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;
}

View 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 }
);
}
}

View 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")
);

File diff suppressed because it is too large Load diff

View 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(),
});