From f134bf26b40e592c5cc5394ce6f29840d9bd20cb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 21 Oct 2025 18:01:50 +0000 Subject: [PATCH] added postcode valiation --- src/app/api/postcode/LookupPostcode.ts | 59 +++++ src/app/api/postcode/[postcode]/route.ts | 31 +++ .../remote-assessment/AddressSearch.tsx | 72 +++--- .../useCreateRemoteAssessment.tsx | 230 ++++++++++++++++++ .../remote-assessment/usePostcodeLookup.ts | 72 ++++++ 5 files changed, 435 insertions(+), 29 deletions(-) create mode 100644 src/app/api/postcode/LookupPostcode.ts create mode 100644 src/app/api/postcode/[postcode]/route.ts create mode 100644 src/app/portfolio/[slug]/remote-assessment/useCreateRemoteAssessment.tsx create mode 100644 src/app/portfolio/[slug]/remote-assessment/usePostcodeLookup.ts 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/[postcode]/route.ts b/src/app/api/postcode/[postcode]/route.ts new file mode 100644 index 0000000..203eaee --- /dev/null +++ b/src/app/api/postcode/[postcode]/route.ts @@ -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 }); +} diff --git a/src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx b/src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx index ab0f092..c7ec3b2 100644 --- a/src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx +++ b/src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx @@ -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([]); const [selectedAddress, setSelectedAddress] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(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 (

Step 1: Search for Address

- {/* Hide postcode/search once address is selected */} {!selectedAddress && (
)} - {error &&

{error}

} + {/* Validation + Error feedback */} + {showInvalid && ( +

+ Invalid postcode — please check and try again. +

+ )} + {showServerError && ( +

+ The postcode service is currently unavailable. Please try again later. +

+ )} {/* Address Dropdown */} {showDropdown && addresses.length > 0 && ( @@ -113,7 +127,7 @@ export default function AddressSearch({ )} - {/* Selected Address */} + {/* Selected Address Display */} {selectedAddress && !showDropdown && (

diff --git a/src/app/portfolio/[slug]/remote-assessment/useCreateRemoteAssessment.tsx b/src/app/portfolio/[slug]/remote-assessment/useCreateRemoteAssessment.tsx new file mode 100644 index 0000000..020fb4f --- /dev/null +++ b/src/app/portfolio/[slug]/remote-assessment/useCreateRemoteAssessment.tsx @@ -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 = >(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; diff --git a/src/app/portfolio/[slug]/remote-assessment/usePostcodeLookup.ts b/src/app/portfolio/[slug]/remote-assessment/usePostcodeLookup.ts new file mode 100644 index 0000000..a8c3eed --- /dev/null +++ b/src/app/portfolio/[slug]/remote-assessment/usePostcodeLookup.ts @@ -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 { + 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;