mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
added postcode valiation
This commit is contained in:
parent
54ccbf93dd
commit
f134bf26b4
5 changed files with 435 additions and 29 deletions
59
src/app/api/postcode/LookupPostcode.ts
Normal file
59
src/app/api/postcode/LookupPostcode.ts
Normal 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" };
|
||||
}
|
||||
31
src/app/api/postcode/[postcode]/route.ts
Normal file
31
src/app/api/postcode/[postcode]/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue