added postcode valiation

This commit is contained in:
Khalim Conn-Kowlessar 2025-10-21 18:01:50 +00:00
parent 54ccbf93dd
commit f134bf26b4
5 changed files with 435 additions and 29 deletions

View file

@ -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<PostcodeLookupResult> {
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" };
}

View file

@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { lookupPostcode } from "../LookupPostcode";
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 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 });
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { Card } from "@/app/shadcn_components/ui/card";
@ -12,6 +12,7 @@ import {
SelectItem,
SelectValue,
} from "@/app/shadcn_components/ui/select";
import usePostcodeLookup from "./usePostcodeLookup";
export default function AddressSearch({
onAddressSelect,
@ -24,51 +25,54 @@ export default function AddressSearch({
}) {
const [addresses, setAddresses] = useState<string[]>([]);
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [triggerSearch, setTriggerSearch] = useState(false);
const { data, isFetching, refetch } = usePostcodeLookup(
postcode,
triggerSearch
);
// When postcode data returns successfully (status 200), populate mock addresses
useEffect(() => {
if (data && data.status === 200 && data.result) {
setAddresses([
`10 Downing Street, London, ${data.result.postcode}`,
`11 Downing Street, London, ${data.result.postcode}`,
`12 Downing Street, London, ${data.result.postcode}`,
]);
setShowDropdown(true);
}
}, [data]);
async function handleSearch() {
setError(null);
setAddresses([]);
if (!postcode.trim()) {
setError("Please enter a postcode");
return;
}
setLoading(true);
await new Promise((r) => setTimeout(r, 800));
// ✅ Replace with OS Places or postcode.io lookup later
setAddresses([
"10 Downing Street, London, SW1A 2AA",
"11 Downing Street, London, SW1A 2AA",
"12 Downing Street, London, SW1A 2AA",
]);
setLoading(false);
setShowDropdown(true);
if (!postcode.trim()) return;
setTriggerSearch(true);
await refetch();
setTriggerSearch(false); // Reset trigger after refetch
}
function handleSelectAddress(value: string) {
setSelectedAddress(value);
setShowDropdown(false);
if (onAddressSelect) onAddressSelect(value);
onAddressSelect(value);
}
function handleChangeAddress() {
setSelectedAddress(null);
setShowDropdown(true);
if (onAddressSelect) onAddressSelect(null);
onAddressSelect(null);
}
const showInvalid = data && data.status === 404;
const showServerError = data && data.status === 500;
return (
<Card className="p-6">
<h2 className="text-xl font-semibold text-brandbrown mb-4">
Step 1: Search for Address
</h2>
{/* Hide postcode/search once address is selected */}
{!selectedAddress && (
<div className="flex gap-2 mb-4">
<Input
@ -79,15 +83,25 @@ export default function AddressSearch({
/>
<Button
onClick={handleSearch}
disabled={loading || !postcode}
disabled={isFetching || !postcode}
className="bg-brandbrown text-white"
>
{loading ? "Searching..." : "Search"}
{isFetching ? "Searching..." : "Search"}
</Button>
</div>
)}
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
{/* Validation + Error feedback */}
{showInvalid && (
<p className="text-sm text-red-600 mb-3">
Invalid postcode please check and try again.
</p>
)}
{showServerError && (
<p className="text-sm text-orange-600 mb-3">
The postcode service is currently unavailable. Please try again later.
</p>
)}
{/* Address Dropdown */}
{showDropdown && addresses.length > 0 && (
@ -113,7 +127,7 @@ export default function AddressSearch({
</div>
)}
{/* Selected Address */}
{/* Selected Address Display */}
{selectedAddress && !showDropdown && (
<div className="relative bg-gray-100 border rounded-xl p-6 mt-4">
<h3 className="text-lg font-semibold text-brandblue mb-2">

View file

@ -0,0 +1,230 @@
"use client";
import { useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { measuresList } from "@/app/db/schema/recommendations";
import { RemoteAssessmentFormValues } from "@/app/portfolio/[slug]/components/FormSchema";
interface EngineTriggerBody {
portfolio_id: string;
housing_type: string;
goal: string;
goal_value: string | null;
trigger_file_path: string;
already_installed_file_path: string;
patches_file_path: string;
non_invasive_recommendations_file_path: string;
valuation_file_path: string;
scenario_name: string;
multi_plan: boolean;
budget: number | null;
event_type: string;
inclusions: (typeof measuresList)[number][];
scenario_id?: string | null;
}
/* ---------- Helpers ---------- */
async function uploadCsvToS3({
presignedUrl,
file,
}: {
presignedUrl: string;
file: Blob;
}) {
const response = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": "text/csv" },
});
if (!response.ok) {
console.error(response);
throw new Error("Failed to upload CSV to S3");
}
console.log("✅ File uploaded successfully:", presignedUrl);
return { success: true };
}
async function generatePresignedUrl({
userId,
portfolioId,
fileKey,
}: {
userId: string;
portfolioId: string;
fileKey: string;
}) {
const response = await fetch("/api/upload/csv", {
method: "POST",
body: JSON.stringify({ userId, portfolioId, fileKey }),
});
if (!response.ok) {
throw new Error("Failed to generate presigned URL");
}
const data = await response.json();
return { ...data, fileKey };
}
function generateS3Keys(userId: string, portfolioId: string) {
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
return {
assetListFileKey: `${userId}/${portfolioId}/${timestamp}/asset_list.csv`,
valuationDataFileKey: `${userId}/${portfolioId}/${timestamp}/valuation_data.csv`,
};
}
const convertToCSV = <T extends Record<string, any>>(data: T[]): string => {
if (data.length === 0) return "";
const headers = Object.keys(data[0]) as (keyof T)[];
const escape = (val: any) =>
val == null
? ""
: /[",\n]/.test(String(val))
? `"${String(val).replace(/"/g, '""')}"`
: String(val);
return [
headers.join(","),
...data.map((r) => headers.map((h) => escape(r[h])).join(",")),
].join("\n");
};
/* ---------- Main Hook ---------- */
function useCreateRemoteAssessment({
portfolioId,
uprn,
addressLineOne,
postcode,
valuation,
propertyType,
builtForm,
measures,
scenarioId,
}: {
portfolioId: string;
uprn: number | undefined | null;
addressLineOne: string;
postcode: string;
valuation: number | undefined | null;
measures: (typeof measuresList)[number][];
propertyType?: string | null;
builtForm?: string | null;
scenarioId?: string | null;
}) {
const { data: session } = useSession();
const userId = String(session?.user.dbId);
if (uprn === undefined || valuation === undefined) {
console.warn("Missing UPRN or valuation, cannot proceed");
}
const { assetListFileKey, valuationDataFileKey } = useMemo(
() => generateS3Keys(userId, portfolioId),
[userId, portfolioId]
);
const uploadMutation = useMutation({
mutationFn: uploadCsvToS3,
});
const presignedMutation = useMutation({
mutationFn: generatePresignedUrl,
onSuccess: (data) => {
let csvFile: Blob;
if (data.fileKey === assetListFileKey) {
const assetList: {
uprn: number | null | undefined;
address: string;
postcode: string;
property_type?: string;
built_form?: string;
}[] = [
{
uprn,
address: addressLineOne,
postcode,
},
];
// if we have property type and built form, include them. Handle typescript optionality
if (propertyType) {
assetList[0]["property_type"] = propertyType;
}
if (builtForm) {
assetList[0]["built_form"] = builtForm;
}
csvFile = new Blob([convertToCSV(assetList)], { type: "text/csv" });
} else {
const valuationData = [{ uprn, valuation }];
csvFile = new Blob([convertToCSV(valuationData)], { type: "text/csv" });
}
uploadMutation.mutate({ file: csvFile, presignedUrl: data.url });
},
});
async function triggerEngine(data: RemoteAssessmentFormValues) {
const triggerBody: EngineTriggerBody = {
scenario_id: scenarioId === "__new__" ? null : scenarioId,
portfolio_id: portfolioId,
housing_type: data.housingType,
goal: data.goal,
goal_value: data.goalValue || null,
trigger_file_path: assetListFileKey,
already_installed_file_path: "",
patches_file_path: "",
non_invasive_recommendations_file_path: "",
valuation_file_path: valuation ? valuationDataFileKey : "", // We only pass a valution filepath if we have a valuation
scenario_name: data.scenario,
inclusions: data.measures,
multi_plan: true,
budget: data.budget || null,
event_type: "remote_assessment",
};
console.log("🚀 Triggering engine with body:", triggerBody);
const response = await fetch("/api/plan/trigger", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(triggerBody),
});
if (!response.ok) throw new Error("Failed to trigger engine");
console.log("✅ Engine triggered successfully");
}
async function handleSubmit(formData: RemoteAssessmentFormValues) {
console.log("Submitting Remote Assessment form:", formData);
await Promise.all([
presignedMutation.mutateAsync({
userId,
portfolioId,
fileKey: assetListFileKey,
}),
presignedMutation.mutateAsync({
userId,
portfolioId,
fileKey: valuationDataFileKey,
}),
]);
await triggerEngine(formData);
}
return {
handleSubmit,
triggerEngine,
isUploading: uploadMutation.isLoading || presignedMutation.isLoading,
hasError: uploadMutation.isError || presignedMutation.isError,
};
}
export default useCreateRemoteAssessment;

View file

@ -0,0 +1,72 @@
"use client";
import { useQuery } from "@tanstack/react-query";
export interface PostcodeResult {
postcode: string;
country: string;
region: string | null;
admin_district: string | null;
latitude: number;
longitude: number;
}
export interface PostcodeLookupResponse {
status: number;
result: PostcodeResult | null;
message?: string;
}
/**
* Calls your /api/postcode/:postcode endpoint.
* Handles 404 gracefully (invalid postcode),
* 500 gracefully (external service issue),
* and only throws on client-side misuse (400).
*/
async function fetchPostcode(
postcode: string
): Promise<PostcodeLookupResponse> {
const res = await fetch(`/api/postcode/${encodeURIComponent(postcode)}`);
const data = await res.json();
switch (res.status) {
case 200:
return { status: 200, result: data.result };
case 404:
// Invalid postcode (user input issue)
return { status: 404, result: null, message: "Invalid postcode" };
case 500:
// External API error
return {
status: 500,
result: null,
message:
"We're having trouble reaching the postcode service. Please try again later.",
};
case 400:
// Bad query from our side (should not happen in production)
throw new Error("Postcode API query malformed (400).");
default:
// Unexpected case
return {
status: res.status,
result: null,
message: data.error || "Unexpected response from postcode API",
};
}
}
/**
* React Query hook for postcode validation and lookup
*/
function usePostcodeLookup(postcode: string, shouldFetch: boolean) {
return useQuery({
queryKey: ["postcode-lookup", postcode],
queryFn: () => fetchPostcode(postcode),
enabled: shouldFetch && !!postcode,
retry: false,
staleTime: 1000 * 60 * 5,
});
}
export default usePostcodeLookup;