From b81a1aaf617bff8690c985fabbc55326a1dc4604 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sat, 18 Apr 2026 18:55:19 +0000 Subject: [PATCH 01/22] added trigger of sqs --- CLAUDE.md | 9 + .../bulk-uploads/[uploadId]/combine/route.ts | 60 +++ .../bulk-uploads/[uploadId]/onboard/route.ts | 163 +++++++ .../bulk-uploads/[uploadId]/route.ts | 50 ++ .../[portfolioId]/bulk-uploads/route.ts | 24 + src/app/api/tasks/[taskId]/summary/route.ts | 40 ++ src/app/api/tasks/route.ts | 54 +++ .../upload/bulk-addresses/confirm/route.ts | 50 ++ src/app/api/upload/bulk-addresses/route.ts | 36 ++ .../portfolio/BulkUploadComingSoonModal.tsx | 440 ++++++++++++++++-- .../[uploadId]/OnboardingProgress.tsx | 129 +++++ .../[uploadId]/StartOnboardingButton.tsx | 87 ++++ .../map-columns/MapColumnsClient.tsx | 271 +++++++++++ .../[uploadId]/map-columns/page.tsx | 33 ++ .../bulk-upload/[uploadId]/page.tsx | 169 +++++++ .../[slug]/(portfolio)/bulk-upload/page.tsx | 143 ++++++ src/app/utils/sqs.ts | 9 +- 17 files changed, 1729 insertions(+), 38 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts create mode 100644 src/app/api/tasks/[taskId]/summary/route.ts create mode 100644 src/app/api/upload/bulk-addresses/confirm/route.ts create mode 100644 src/app/api/upload/bulk-addresses/route.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartOnboardingButton.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a88bf88 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# Claude guidance for this project + +## React + +- **Avoid `useEffect` and `useMemo`.** Derive values inline, use Server Components + Route Handlers, event handlers, or `useSyncExternalStore` instead. If a hook is genuinely the only option, flag it and ask before using it. + +## Next.js 15 route handlers + +- `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring. diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts new file mode 100644 index 0000000..1ebb20b --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts @@ -0,0 +1,60 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { sendToQueue } from "@/app/utils/sqs"; + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + + const [upload] = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!upload.taskId) + return NextResponse.json({ error: "Upload has no task" }, { status: 422 }); + if (upload.combinedOutputS3Uri) + return NextResponse.json({ alreadyCombined: true }, { status: 200 }); + + const queueName = process.env.BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME; + if (!queueName) { + console.error("BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME not set"); + return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); + } + + const [subTask] = await db + .insert(subTasks) + .values({ + taskId: upload.taskId, + status: "waiting", + }) + .returning(); + + const messageBody = { task_id: upload.taskId, sub_task_id: subTask.id }; + + try { + await sendToQueue(messageBody, { queueName }); + } catch (err) { + console.error("Failed to send combiner SQS message:", err); + return NextResponse.json({ error: "Failed to queue combiner job" }, { status: 500 }); + } + + await db + .update(subTasks) + .set({ inputs: JSON.stringify(messageBody) }) + .where(eq(subTasks.id, subTask.id)); + + return NextResponse.json({ taskId: upload.taskId, subTaskId: subTask.id }, { status: 200 }); +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts new file mode 100644 index 0000000..5a6d4b0 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts @@ -0,0 +1,163 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { tasks } from "@/app/db/schema/tasks/tasks"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { createS3Client } from "@/app/utils/s3"; +import { sendToQueue } from "@/app/utils/sqs"; +import S3 from "aws-sdk/clients/s3"; +import * as XLSX from "xlsx"; + +const FIELD_RENAME: Record = { + address_1: "Address 1", + address_2: "Address 2", + address_3: "Address 3", + postcode: "postcode", + internal_reference: "Internal Reference", +}; + +const BodySchema = z.object({ + taskId: z.string().uuid(), + subTaskId: z.string().uuid(), +}); + +function transformFile( + buffer: Buffer, + columnMapping: Record +): { csv: string; error?: never } | { csv?: never; error: string } { + const wb = XLSX.read(buffer, { type: "buffer" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); + + if (rows.length === 0) return { error: "Empty file" }; + + const sourceHeaders = Object.keys(rows[0]); + const outputHeaders: string[] = []; + const sourceToOutput: Record = {}; + + for (const src of sourceHeaders) { + const mapped = columnMapping[src]; + if (!mapped || mapped === "skip") continue; + const renamed = FIELD_RENAME[mapped] ?? mapped; + outputHeaders.push(renamed); + sourceToOutput[src] = renamed; + } + + if (!outputHeaders.includes("Address 1")) + return { error: 'Mapping must include "Address 1"' }; + if (!outputHeaders.includes("postcode")) + return { error: 'Mapping must include "postcode"' }; + + const outputRows = rows.map((row) => { + const out: Record = {}; + for (const [src, renamed] of Object.entries(sourceToOutput)) { + out[renamed] = row[src] ?? ""; + } + return out; + }); + + const outSheet = XLSX.utils.json_to_sheet(outputRows, { header: outputHeaders }); + return { csv: XLSX.utils.sheet_to_csv(outSheet) }; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { portfolioId, uploadId } = await params; + + let body; + try { + body = BodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + const [upload] = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (upload.status !== "mapping_complete") + return NextResponse.json({ error: "Upload not ready for onboarding" }, { status: 422 }); + if (!upload.columnMapping) + return NextResponse.json({ error: "Column mapping missing" }, { status: 422 }); + + const s3 = createS3Client(); + const outputS3 = new S3({ + region: process.env.RETROFIT_DATA_DEV_REGION, + accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, + secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, + }); + const outputBucket = process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!; + const bucket = upload.s3Bucket; + + let fileBuffer: Buffer; + try { + const obj = await s3 + .getObject({ Bucket: bucket, Key: upload.s3Key }) + .promise(); + fileBuffer = Buffer.from(obj.Body as Uint8Array); + } catch (err) { + console.error("Failed to read source file from S3:", err); + return NextResponse.json({ error: "Failed to read source file" }, { status: 500 }); + } + + const result = transformFile(fileBuffer, upload.columnMapping); + if (result.error) return NextResponse.json({ error: result.error }, { status: 422 }); + + const transformedKey = `bulk_onboarding_inputs/${portfolioId}/${uploadId}.csv`; + try { + await outputS3 + .putObject({ + Bucket: outputBucket, + Key: transformedKey, + Body: result.csv, + ContentType: "text/csv", + }) + .promise(); + } catch (err) { + console.error("Failed to upload transformed CSV:", err); + return NextResponse.json({ error: "Failed to store transformed file" }, { status: 500 }); + } + + const s3Uri = `s3://${outputBucket}/${transformedKey}`; + const queueName = process.env.POSTCODE_SPLITTER_QUEUE_NAME; + if (!queueName) { + console.error("POSTCODE_SPLITTER_QUEUE_NAME not set"); + return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); + } + + try { + await sendToQueue( + { task_id: body.taskId, sub_task_id: body.subTaskId, s3_uri: s3Uri }, + { queueName } + ); + } catch (err) { + console.error("Failed to send SQS message:", err); + return NextResponse.json({ error: "Failed to queue onboarding job" }, { status: 500 }); + } + + await Promise.all([ + db.update(bulkAddressUploads) + .set({ status: "processing", taskId: body.taskId }) + .where(eq(bulkAddressUploads.id, uploadId)), + db.update(tasks) + .set({ status: "in progress" }) + .where(eq(tasks.id, body.taskId)), + db.update(subTasks) + .set({ inputs: JSON.stringify({ task_id: body.taskId, sub_task_id: body.subTaskId, s3_uri: s3Uri }) }) + .where(eq(subTasks.id, body.subTaskId)), + ]); + + return NextResponse.json({ taskId: body.taskId }, { status: 200 }); +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts new file mode 100644 index 0000000..b61e05d --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts @@ -0,0 +1,50 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const PatchSchema = z.object({ + columnMapping: z.record(z.string(), z.string()), +}); + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const { uploadId } = await params; + + let body; + try { + body = PatchSchema.parse(await request.json()); + } catch { + return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); + } + + const values = Object.values(body.columnMapping); + const hasAddress = values.includes("address_1"); + const hasPostcode = values.includes("postcode"); + if (!hasAddress || !hasPostcode) { + return NextResponse.json( + { msg: "Mapping must include address_1 and postcode." }, + { status: 422 } + ); + } + + try { + const [updated] = await db + .update(bulkAddressUploads) + .set({ columnMapping: body.columnMapping, status: "mapping_complete" }) + .where(eq(bulkAddressUploads.id, uploadId)) + .returning(); + + if (!updated) { + return NextResponse.json({ msg: "Not found" }, { status: 404 }); + } + + return NextResponse.json(updated, { status: 200 }); + } catch (error) { + console.error("Failed to save column mapping:", error); + return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts new file mode 100644 index 0000000..86bd00f --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts @@ -0,0 +1,24 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { eq, desc } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ portfolioId: string }> } +) { + const { portfolioId } = await params; + + try { + const uploads = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.portfolioId, portfolioId)) + .orderBy(desc(bulkAddressUploads.createdAt)); + + return NextResponse.json(uploads, { status: 200 }); + } catch (error) { + console.error("Failed to fetch bulk uploads:", error); + return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/tasks/[taskId]/summary/route.ts b/src/app/api/tasks/[taskId]/summary/route.ts new file mode 100644 index 0000000..6e5cb5c --- /dev/null +++ b/src/app/api/tasks/[taskId]/summary/route.ts @@ -0,0 +1,40 @@ +import { db } from "@/app/db/db"; +import { tasks } from "@/app/db/schema/tasks/tasks"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; +import { eq, count, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ taskId: string }> } +) { + const { taskId } = await params; + + try { + const [row] = await db + .select({ + id: tasks.id, + taskSource: tasks.taskSource, + status: tasks.status, + service: tasks.service, + jobStarted: tasks.jobStarted, + jobCompleted: tasks.jobCompleted, + updatedAt: tasks.updatedAt, + totalSubtasks: count(subTasks.id), + completedSubtasks: sql`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`, + failedSubtasks: sql`count(case when lower(${subTasks.status}) in ('failed', 'failure', 'error') then 1 end)::int`, + }) + .from(tasks) + .leftJoin(subTasks, eq(subTasks.taskId, tasks.id)) + .where(eq(tasks.id, taskId)) + .groupBy(tasks.id) + .limit(1); + + if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + return NextResponse.json(row); + } catch (error) { + console.error("Error fetching task summary:", error); + return NextResponse.json({ error: "Failed to fetch task summary" }, { status: 500 }); + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index d11a239..dc6e830 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,7 +1,61 @@ import { db } from "@/app/db/db"; import { tasks } from "@/app/db/schema/tasks/tasks"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; import { desc, count } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; + +const CreateTaskSchema = z.object({ + taskSource: z.string().min(1), + service: z.string().optional(), + source: z.literal("portfolio_id").optional(), + sourceId: z.string().optional(), + inputs: z.record(z.unknown()).optional(), +}); + +export async function POST(request: NextRequest) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + let body; + try { + body = CreateTaskSchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + try { + const now = new Date(); + + const [task] = await db + .insert(tasks) + .values({ + taskSource: body.taskSource, + service: body.service, + source: body.source, + sourceId: body.sourceId, + status: "waiting", + jobStarted: now, + }) + .returning(); + + const [subTask] = await db + .insert(subTasks) + .values({ + taskId: task.id, + status: "waiting", + inputs: body.inputs ? JSON.stringify(body.inputs) : null, + }) + .returning(); + + return NextResponse.json({ taskId: task.id, subTaskId: subTask.id }, { status: 201 }); + } catch (error) { + console.error("Failed to create task:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} export async function GET(request: NextRequest) { try { diff --git a/src/app/api/upload/bulk-addresses/confirm/route.ts b/src/app/api/upload/bulk-addresses/confirm/route.ts new file mode 100644 index 0000000..edc6357 --- /dev/null +++ b/src/app/api/upload/bulk-addresses/confirm/route.ts @@ -0,0 +1,50 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const BodySchema = z.object({ + fileKey: z.string(), + filename: z.string(), + portfolioId: z.string(), + userId: z.string(), + sourceHeaders: z.array(z.string()).default([]), +}); + +export async function POST(request: NextRequest) { + let body; + try { + body = BodySchema.parse(await request.json()); + } catch (error) { + console.error("Invalid input:", error); + return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); + } + + const bucket = process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME; + if (!bucket) { + console.error("RETROFIT_PLAN_INPUT_BUCKET_NAME not set"); + return NextResponse.json({ msg: "Server misconfiguration" }, { status: 500 }); + } + + try { + const [record] = await db + .insert(bulkAddressUploads) + .values({ + portfolioId: body.portfolioId, + userId: body.userId, + s3Bucket: bucket, + s3Key: body.fileKey, + filename: body.filename, + sourceHeaders: body.sourceHeaders, + }) + .returning(); + + return NextResponse.json( + { id: record.id, s3Key: record.s3Key, s3Bucket: record.s3Bucket, status: record.status }, + { status: 201 } + ); + } catch (error) { + console.error("Failed to record upload:", error); + return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/upload/bulk-addresses/route.ts b/src/app/api/upload/bulk-addresses/route.ts new file mode 100644 index 0000000..9eb1c41 --- /dev/null +++ b/src/app/api/upload/bulk-addresses/route.ts @@ -0,0 +1,36 @@ +import { createS3Client } from "@/app/utils/s3"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const BodySchema = z.object({ + userId: z.string(), + portfolioId: z.string(), + fileKey: z.string(), + contentType: z.string(), +}); + +export async function POST(request: NextRequest) { + let body; + try { + body = BodySchema.parse(await request.json()); + } catch (error) { + console.error("Invalid input:", error); + return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); + } + + try { + const s3 = createS3Client(); + + const preSignedUrl = await s3.getSignedUrlPromise("putObject", { + Bucket: process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME, + Key: body.fileKey, + ContentType: body.contentType, + Expires: 5 * 60, + }); + + return NextResponse.json({ url: preSignedUrl }, { status: 200 }); + } catch (error) { + console.error(error); + return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx index 2de2b42..1cd47bf 100644 --- a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx +++ b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx @@ -4,11 +4,32 @@ import { Dialog, DialogBackdrop, DialogPanel, + DialogTitle, Transition, TransitionChild, } from "@headlessui/react"; -import { Fragment } from "react"; -import { XMarkIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; +import { Fragment, useRef, useState, DragEvent } from "react"; +import * as XLSX from "xlsx"; +import { + XMarkIcon, + DocumentTextIcon, + ArrowDownTrayIcon, + CloudArrowUpIcon, + InformationCircleIcon, + ArrowRightIcon, + CheckCircleIcon, + ExclamationCircleIcon, +} from "@heroicons/react/24/outline"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +const MAX_FILE_SIZE_MB = 50; +const ALLOWED_EXTENSIONS = [".csv", ".xlsx", ".xls"]; +const CONTENT_TYPES: Record = { + ".csv": "text/csv", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", +}; interface BulkUploadComingSoonModalProps { isOpen: boolean; @@ -16,13 +37,212 @@ interface BulkUploadComingSoonModalProps { portfolioId: string; } +function downloadTemplate() { + const ws = XLSX.utils.aoa_to_sheet([["Internal Reference (Optional)", "Address", "Postcode"]]); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Properties"); + XLSX.writeFile(wb, "bulk_upload_template.xlsx"); +} + +function getFileExtension(filename: string): string { + return filename.slice(filename.lastIndexOf(".")).toLowerCase(); +} + +function generateS3Key(userId: string, portfolioId: string, ext: string): string { + const timestamp = new Date().toISOString().replace(/[:.-]/g, ""); + return `bulk-addresses/${userId}/${portfolioId}/${timestamp}/addresses${ext}`; +} + +function validateFile(file: File): string | null { + const sizeMB = file.size / (1024 * 1024); + if (sizeMB > MAX_FILE_SIZE_MB) { + return `File too large. Max ${MAX_FILE_SIZE_MB}MB.`; + } + const ext = getFileExtension(file.name); + if (!ALLOWED_EXTENSIONS.includes(ext)) { + return "Only CSV or Excel files allowed."; + } + return null; +} + +async function validateHeaders(file: File): Promise<{ error: string | null; headers: string[] }> { + const ext = getFileExtension(file.name); + let headers: string[] = []; + + if (ext === ".csv") { + const text = await file.text(); + const firstLine = text.split(/\r?\n/)[0] ?? ""; + headers = firstLine.split(",").map((h) => h.trim().replace(/^["']|["']$/g, "")); + } else { + const buffer = await file.arrayBuffer(); + const wb = XLSX.read(buffer, { sheetRows: 1 }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }); + headers = ((rows[0] as string[]) ?? []).map((h) => String(h ?? "").trim()); + } + + const normalised = headers.map((h) => h.toLowerCase()); + const hasAddress = normalised.some((h) => h.startsWith("address")); + const hasPostcode = normalised.some((h) => h === "postcode"); + + if (!hasAddress && !hasPostcode) { + return { error: "Missing required columns: Address and Postcode.", headers }; + } + if (!hasAddress) { + return { error: "Missing required column: Address (or Address 1, Address 2, etc.).", headers }; + } + if (!hasPostcode) { + return { error: 'Missing required column: "Postcode".', headers }; + } + return { error: null, headers }; +} + +async function getPresignedUrl( + userId: string, + portfolioId: string, + fileKey: string, + contentType: string +): Promise { + const res = await fetch("/api/upload/bulk-addresses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, portfolioId, fileKey, contentType }), + }); + if (!res.ok) throw new Error("Failed to generate upload URL."); + const data = await res.json(); + return data.url; +} + export default function BulkUploadComingSoonModal({ isOpen, onClose, + portfolioId, }: BulkUploadComingSoonModalProps) { + const session = useSession(); + const router = useRouter(); + const fileInputRef = useRef(null); + + const [isDragging, setIsDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [sourceHeaders, setSourceHeaders] = useState([]); + const [validationError, setValidationError] = useState(null); + const [validating, setValidating] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); + const [uploadError, setUploadError] = useState(null); + + async function handleFile(file: File) { + setUploadError(null); + setSelectedFile(null); + setValidationError(null); + + const sizeOrTypeError = validateFile(file); + if (sizeOrTypeError) { + setValidationError(sizeOrTypeError); + return; + } + + setValidating(true); + const { error: headerError, headers } = await validateHeaders(file); + setValidating(false); + + if (headerError) { + setValidationError(headerError); + return; + } + + setSourceHeaders(headers); + setSelectedFile(file); + } + + function handleDragOver(e: DragEvent) { + e.preventDefault(); + setIsDragging(true); + } + + function handleDragLeave() { + setIsDragging(false); + } + + function handleDrop(e: DragEvent) { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + } + + function handleInputChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) handleFile(file); + } + + function handleClose() { + setSelectedFile(null); + setSourceHeaders([]); + setValidationError(null); + setValidating(false); + setUploadError(null); + setUploading(false); + setUploadProgress(null); + onClose(); + } + + async function handleUpload() { + const userId = String(session.data?.user?.dbId ?? ""); + if (!selectedFile || !userId) return; + + setUploading(true); + setUploadProgress(0); + setUploadError(null); + + try { + const ext = getFileExtension(selectedFile.name); + const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const fileKey = generateS3Key(userId, portfolioId, ext); + + const presignedUrl = await getPresignedUrl(userId, portfolioId, fileKey, contentType); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", presignedUrl); + xhr.setRequestHeader("Content-Type", contentType); + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + setUploadProgress(Math.round((e.loaded / e.total) * 100)); + } + }); + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error(`S3 upload failed: ${xhr.status}`)); + }; + xhr.onerror = () => reject(new Error("Network error during upload")); + xhr.send(selectedFile); + }); + + const confirmRes = await fetch("/api/upload/bulk-addresses/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileKey, filename: selectedFile.name, portfolioId, userId, sourceHeaders }), + }); + if (!confirmRes.ok) throw new Error("Failed to record upload."); + + const { id: uploadId } = await confirmRes.json(); + router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}/map-columns`); + onClose(); + } catch (err) { + setUploadError("Upload failed. Please try again, or contact a Domna representative if the issue persists."); + } finally { + setUploading(false); + setUploadProgress(null); + } + } + + const canUpload = !!selectedFile && !uploading && !validating; + return ( - - + + + {/* Backdrop */} - + + {/* Panel */}
- - - -
-
- -
+ + {/* Header */} +
- - Coming Soon - -

- Bulk Address Upload -

-

- Upload multiple addresses in one go. This feature is currently in development - and will be available soon. + + Bulk Upload: New Properties + +

+ This workflow is designed for adding new residential or commercial + assets to your portfolio. Upload your dataset to begin the + transformation.

-
+ + {/* Content */} +
+ + {/* Template section */} +
+
+
+ +
+
+

Required Template Format

+

+ Must contain:{" "} + + Address, Postcode + +

+
+
+ +
+ + {/* Dropzone */} +
!uploading && fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={`border-2 border-dashed rounded-2xl p-12 flex flex-col items-center justify-center transition-colors ${ + uploading || validating + ? "border-gray-200 bg-gray-50 cursor-default" + : validationError + ? "border-red-300 bg-red-50 cursor-pointer" + : isDragging + ? "border-midblue bg-blue-50 cursor-copy" + : selectedFile + ? "border-green-400 bg-green-50 cursor-pointer" + : "border-gray-200 hover:border-gray-300 hover:bg-gray-50 cursor-pointer" + }`} + > + e.stopPropagation()} + /> + + {validating ? ( + <> +
+ +
+

Checking headers…

+

Validating column structure

+ + ) : uploading ? ( + <> +
+ +
+

Uploading…

+

{selectedFile?.name}

+
+
+
+

{uploadProgress ?? 0}%

+ + ) : validationError ? ( + <> + +

{validationError}

+

Click to choose a different file

+ + ) : selectedFile ? ( + <> + +

{selectedFile.name}

+

Click to change file

+ + ) : ( + <> +
+ +
+

+ Drag and drop CSV or XLSX +

+

+ or click to browse · Max {MAX_FILE_SIZE_MB}MB +

+ + )} +
+ + {/* Upload error */} + {uploadError && ( +

+ + {uploadError} +

+ )} + + {/* Info strip */} +
+ + + Properties will be automatically validated against national + architectural databases. + +
+
+ + {/* Footer */} +
+
+ + +
+
+ +
+
+
diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx new file mode 100644 index 0000000..a4afb30 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import Link from "next/link"; + +interface TaskData { + id: string; + taskSource: string; + status: string; + totalSubtasks: number; + completedSubtasks: number; + failedSubtasks: number; +} + +interface Props { + taskId: string; + portfolioSlug: string; + portfolioId: string; + uploadId: string; + isDomnaUser: boolean; +} + +const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]); +const FAILED_STATUSES = new Set(["failed", "failure", "error"]); + +export default function OnboardingProgress({ + taskId, + portfolioSlug, + portfolioId, + uploadId, + isDomnaUser, +}: Props) { + const [data, setData] = useState(null); + const [fetchError, setFetchError] = useState(false); + const intervalRef = useRef | null>(null); + const combineFiredRef = useRef(false); + + useEffect(() => { + async function poll() { + try { + const res = await fetch(`/api/tasks/${taskId}/summary`); + if (!res.ok) { setFetchError(true); return; } + const json: TaskData = await res.json(); + setData(json); + const status = json.status.toLowerCase(); + if (TERMINAL_STATUSES.has(status)) { + if (intervalRef.current) clearInterval(intervalRef.current); + if (!FAILED_STATUSES.has(status) && !combineFiredRef.current) { + combineFiredRef.current = true; + fetch(`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/combine`, { + method: "POST", + }).catch((err) => console.error("Failed to trigger combiner:", err)); + } + } + } catch { + setFetchError(true); + } + } + + poll(); + intervalRef.current = setInterval(poll, 3000); + return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; + }, [taskId, portfolioId, uploadId]); + + if (fetchError) return null; + if (!data) { + return ( +
+ + Loading progress… +
+ ); + } + + const total = data.totalSubtasks; + const complete = data.completedSubtasks; + const failed = data.failedSubtasks; + const percent = total > 0 ? Math.round((complete / total) * 100) : 0; + const isDone = TERMINAL_STATUSES.has(data.status.toLowerCase()); + const isFailed = ["failed", "failure", "error"].includes(data.status.toLowerCase()); + + return ( +
+ {/* Progress bar */} +
+
0 ? `${percent}%` : "4%" }} + /> +
+ + {/* Counts */} +
+ {total > 0 && ( + + {complete} / {total} batches complete + + )} + {failed > 0 && ( + + + {failed} failed + + )} + {!isDone && ( + + + Running + + )} + {isDone && !isFailed && ( + + + Complete + + )} +
+ + {isDomnaUser && ( + + View detailed logs + + )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartOnboardingButton.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartOnboardingButton.tsx new file mode 100644 index 0000000..60dad11 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartOnboardingButton.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowRightIcon } from "@heroicons/react/24/outline"; + +interface Props { + portfolioId: string; + uploadId: string; + filename: string; +} + +export default function StartOnboardingButton({ portfolioId, uploadId, filename }: Props) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleStart() { + setLoading(true); + setError(null); + + try { + const taskRes = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + taskSource: `Address Onboarding – ${filename}`, + service: "address2uprn", + source: "portfolio_id", + sourceId: portfolioId, + inputs: { bulk_upload_id: uploadId }, + }), + }); + + if (!taskRes.ok) { + const data = await taskRes.json().catch(() => ({})); + throw new Error(data.error ?? "Failed to create task"); + } + + const { taskId, subTaskId } = await taskRes.json(); + + const onboardRes = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/onboard`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ taskId, subTaskId }), + } + ); + + if (!onboardRes.ok) { + const data = await onboardRes.json().catch(() => ({})); + throw new Error(data.error ?? "Failed to start onboarding"); + } + + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + setLoading(false); + } + } + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx new file mode 100644 index 0000000..1915282 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + ArrowLeftIcon, + ArrowRightIcon, + TableCellsIcon, + ArrowsRightLeftIcon, +} from "@heroicons/react/24/outline"; + +const INTERNAL_FIELDS = [ + { value: "address_1", label: "Address 1", required: true }, + { value: "address_2", label: "Address 2", required: false }, + { value: "address_3", label: "Address 3", required: false }, + { value: "postcode", label: "Postcode", required: true }, + { value: "internal_reference", label: "Internal Reference (Optional)", required: false }, + { value: "skip", label: "Skip this column", required: false }, +]; + +const REQUIRED_VALUES = ["address_1", "postcode"]; + +function autoDetect(header: string): string { + const h = header.toLowerCase().replace(/[\s_\-]/g, ""); + if (/^(address|addr)(line)?(1|one)?$/.test(h)) return "address_1"; + if (/^(address|addr)(line)?(2|two)|^street$/.test(h)) return "address_2"; + if (/^(address|addr)(line)?(3|three)|^locality$|^town$|^city$/.test(h)) return "address_3"; + if (/^post(al)?code$|^postcode$|^pcode$/.test(h)) return "postcode"; + if (/^(internal)?ref(erence)?$|^id$/.test(h)) return "internal_reference"; + return "skip"; +} + +function buildInitialMapping( + headers: string[], + existing?: Record +): Record { + const mapping: Record = {}; + for (const h of headers) { + mapping[h] = existing?.[h] ?? autoDetect(h); + } + return mapping; +} + +interface Props { + portfolioId: string; + uploadId: string; + filename: string; + sourceHeaders: string[]; + existingMapping?: Record; +} + +export default function MapColumnsClient({ + portfolioId, + uploadId, + filename, + sourceHeaders, + existingMapping, +}: Props) { + const router = useRouter(); + const [mapping, setMapping] = useState>( + buildInitialMapping(sourceHeaders, existingMapping) + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const mappedValues = Object.values(mapping).filter((v) => v !== "skip"); + const missingRequired = REQUIRED_VALUES.filter((r) => !mappedValues.includes(r)); + const canSubmit = missingRequired.length === 0 && !submitting; + + function setField(header: string, value: string) { + setMapping((prev) => ({ ...prev, [header]: value })); + } + + async function handleSubmit() { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ columnMapping: mapping }), + } + ); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.msg ?? "Failed to save mapping."); + } + + router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + setSubmitting(false); + } + } + + return ( +
+ {/* Breadcrumb + step */} +
+

+ Bulk Uploads › Column Remapper +

+
+ + Step 2 of 3 + +
+ {[1, 2, 3].map((s) => ( +
+ ))} +
+
+
+ + {/* Header */} +
+

+ Column Remapper +

+

+ Align your spreadsheet headers with our internal property data structure to + ensure accurate address processing. +

+
+ + {/* Table */} +
+ {/* Column headers */} +
+ + Spreadsheet Header + + + + Internal Field Mapping + + + Status + +
+ + {sourceHeaders.length === 0 ? ( +
+ No headers found in this file. +
+ ) : ( +
+ {sourceHeaders.map((header) => { + const value = mapping[header] ?? "skip"; + const isMapped = value !== "skip"; + return ( +
+ {/* Source header */} +
+
+ +
+
+

{header}

+

Source column

+
+
+ + {/* Arrow */} +
+ +
+ + {/* Dropdown */} +
+ +
+ + {/* Status badge */} +
+ + + {isMapped ? "Mapped" : "Skipped"} + +
+
+ ); + })} +
+ )} +
+ + {/* Validation error */} + {missingRequired.length > 0 && ( +

+ Required fields not yet mapped:{" "} + {missingRequired + .map((r) => INTERNAL_FIELDS.find((f) => f.value === r)?.label) + .join(", ")} +

+ )} + {error &&

{error}

} + + {/* Footer */} +
+ + + Back + + +
+ + Cancel + + +
+
+ + {/* Pro tip */} +
+

+ Pro Tip +

+

+ “Ensure your source file doesn't have blank headers. Any column mapped to + “Skip” will be ignored during import.” +

+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx new file mode 100644 index 0000000..3ce0ac6 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx @@ -0,0 +1,33 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { eq } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { redirect, notFound } from "next/navigation"; +import MapColumnsClient from "./MapColumnsClient"; + +export default async function MapColumnsPage(props: { + params: Promise<{ slug: string; uploadId: string }>; +}) { + const { slug, uploadId } = await props.params; + const session = await getServerSession(AuthOptions); + if (!session) redirect("/login"); + + const [upload] = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) notFound(); + + return ( + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx new file mode 100644 index 0000000..5f7ca9b --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx @@ -0,0 +1,169 @@ +"use server"; + +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { eq } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { redirect, notFound } from "next/navigation"; +import Link from "next/link"; +import { + ArrowLeftIcon, + ArrowRightIcon, + CheckCircleIcon, + ClockIcon, + ExclamationCircleIcon, + ArrowPathIcon, +} from "@heroicons/react/24/outline"; +import StartOnboardingButton from "./StartOnboardingButton"; +import OnboardingProgress from "./OnboardingProgress"; + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +const STATUS_CONFIG = { + ready_for_processing: { + icon: ClockIcon, + iconBg: "bg-amber-50", + iconColor: "text-amber-500", + title: "Awaiting column mapping", + body: "Map your spreadsheet columns to our internal fields before processing can begin.", + cta: true, + }, + mapping_complete: { + icon: CheckCircleIcon, + iconBg: "bg-blue-50", + iconColor: "text-blue-500", + title: "Mapping complete", + body: "Column mapping saved. Start onboarding to begin matching your addresses to UPRNs.", + cta: true, + }, + processing: { + icon: ArrowPathIcon, + iconBg: "bg-blue-50", + iconColor: "text-blue-500", + title: "Processing…", + body: "Your file is currently being processed. This may take a few minutes.", + cta: false, + }, + complete: { + icon: CheckCircleIcon, + iconBg: "bg-green-50", + iconColor: "text-green-500", + title: "Processing complete", + body: "All addresses have been imported into your portfolio.", + cta: false, + }, + failed: { + icon: ExclamationCircleIcon, + iconBg: "bg-red-50", + iconColor: "text-red-500", + title: "Processing failed", + body: "Something went wrong during processing. Contact a Domna representative for assistance.", + cta: false, + }, +} as const; + +export default async function BulkUploadDetailPage(props: { + params: Promise<{ slug: string; uploadId: string }>; +}) { + const { slug, uploadId } = await props.params; + const session = await getServerSession(AuthOptions); + if (!session) redirect("/login"); + const isDomnaUser = !!session.user?.email?.endsWith("@domna.homes"); + + const [upload] = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) notFound(); + + const statusKey = upload.status as keyof typeof STATUS_CONFIG; + const config = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.ready_for_processing; + const Icon = config.icon; + + return ( +
+ {/* Back */} + + + Back to uploads + + + {/* Header */} +
+

+ Bulk Upload +

+

+ {upload.filename} +

+

Uploaded {formatDate(upload.createdAt)}

+
+ + {/* Status card */} +
+
+
+ +
+
+

{config.title}

+

{config.body}

+ + {statusKey === "ready_for_processing" && ( + + Map Columns + + + )} + + {statusKey === "mapping_complete" && ( +
+ + Edit column mapping + + + +
+ )} + + {(statusKey === "processing" || statusKey === "complete" || statusKey === "failed") && + upload.taskId && ( + + )} +
+
+
+ +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx new file mode 100644 index 0000000..cc5a949 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx @@ -0,0 +1,143 @@ +"use server"; + +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { eq, desc } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { + ArrowRightIcon, + DocumentTextIcon, + CloudArrowUpIcon, +} from "@heroicons/react/24/outline"; + +const STATUS_LABELS: Record = { + ready_for_processing: { label: "Ready", classes: "bg-amber-100 text-amber-700" }, + processing: { label: "Processing", classes: "bg-blue-100 text-blue-700" }, + complete: { label: "Complete", classes: "bg-green-100 text-green-700" }, + failed: { label: "Failed", classes: "bg-red-100 text-red-700" }, +}; + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +export default async function BulkUploadListPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + const session = await getServerSession(AuthOptions); + if (!session) redirect("/login"); + + const uploads = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.portfolioId, slug)) + .orderBy(desc(bulkAddressUploads.createdAt)); + + return ( +
+ {/* Header */} +
+

+ Portfolio › Bulk Uploads +

+

+ Batch Uploads +

+

+ Select an upload to continue processing, or start a new import. +

+
+ + {uploads.length === 0 ? ( + /* Empty state */ +
+
+ +
+

No uploads yet

+

+ Use the Bulk Upload button on your portfolio to get started. +

+
+ ) : ( + /* Upload list */ +
+ {/* Column headers */} +
+ + File + + + Uploaded + + + Status + + +
+ + {uploads.map((upload) => { + const status = STATUS_LABELS[upload.status] ?? { + label: upload.status, + classes: "bg-gray-100 text-gray-600", + }; + return ( + + {/* Filename */} +
+
+ +
+
+

+ {upload.filename} +

+

+ {upload.s3Key.split("/").pop()} +

+
+
+ + {/* Date */} +
+

+ {formatDate(upload.createdAt)} +

+
+ + {/* Status badge */} +
+ + + {status.label} + +
+ + {/* Arrow */} +
+ +
+ + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/utils/sqs.ts b/src/app/utils/sqs.ts index e653fee..285fbd9 100644 --- a/src/app/utils/sqs.ts +++ b/src/app/utils/sqs.ts @@ -16,19 +16,20 @@ const sqsClient = new SQSClient({ }, }); -let cachedQueueUrl: string | null = null; +const queueUrlCache = new Map(); // Export if you want to reuse elsewhere export async function getQueueUrl(queueName: string): Promise { - if (cachedQueueUrl) return cachedQueueUrl; + const cached = queueUrlCache.get(queueName); + if (cached) return cached; const resp = await sqsClient.send( new GetQueueUrlCommand({ QueueName: queueName }) ); if (!resp.QueueUrl) throw new Error(`Could not resolve SQS URL for queue: ${queueName}`); - cachedQueueUrl = resp.QueueUrl; - return cachedQueueUrl; + queueUrlCache.set(queueName, resp.QueueUrl); + return resp.QueueUrl; } type SendOptions = { From 0afb2c14c7913fd326325da1040502be0c79cd7b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sat, 18 Apr 2026 19:08:56 +0000 Subject: [PATCH 02/22] added backlog to help me manage claude things --- .devcontainer/Dockerfile | 3 + .devcontainer/devcontainer.json | 4 +- .mcp.json | 9 + CLAUDE.md | 8 + backlog/config.yml | 14 + ...INER_QUEUE_NAME-to-staging-and-prod-env.md | 26 + ...biner-queue-to-assessment-model-runtime.md | 25 + ...bda-queue-via-terraform-to-staging-prod.md | 26 + ...- Smoke-test-combiner-end-to-end-on-dev.md | 27 + ...-next-time-bulk_address_uploads-touched.md | 28 + src/app/db/migrations/0179_mighty_cardiac.sql | 2 - src/app/db/migrations/meta/0178_snapshot.json | 32 +- src/app/db/migrations/meta/0179_snapshot.json | 6910 ----------------- src/app/db/migrations/meta/_journal.json | 7 - 14 files changed, 190 insertions(+), 6931 deletions(-) create mode 100644 .mcp.json create mode 100644 backlog/config.yml create mode 100644 backlog/tasks/task-1 - Add-BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME-to-staging-and-prod-env.md create mode 100644 backlog/tasks/task-2 - Grant-sqs-SendMessage-IAM-on-combiner-queue-to-assessment-model-runtime.md create mode 100644 backlog/tasks/task-3 - Deploy-bulk_address2uprn_combiner-Lambda-queue-via-terraform-to-staging-prod.md create mode 100644 backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md create mode 100644 backlog/tasks/task-5 - Squash-migrations-0178-DROP-0179-re-ADD-next-time-bulk_address_uploads-touched.md delete mode 100644 src/app/db/migrations/0179_mighty_cardiac.sql delete mode 100644 src/app/db/migrations/meta/0179_snapshot.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 42f93fa..d3c123c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -23,6 +23,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && node -v \ && npm -v +# Install Backlog.md (task/todo CLI: https://github.com/MrLesk/Backlog.md) +RUN npm install -g backlog.md + # # Install aws # RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" # RUN unzip awscliv2.zip diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 36d8b4d..72efbbc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,8 +5,8 @@ "remoteUser": "vscode", "workspaceFolder": "/workspaces/assessment-model", "postStartCommand": "bash .devcontainer/post-install.sh", - "forwardPorts": [3000], # For vscode - "appPort": ["3000:3000"], # For devcontainer shell + "forwardPorts": [3000, 6420], # 3000 = Next.js, 6420 = Backlog.md browser + "appPort": ["3000:3000", "6420:6420"], # For devcontainer shell "mounts": [ // Optional, just makes getting from Downloads (local env) easier "source=${localEnv:HOME},target=/workspaces/home,type=bind" diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ece32da --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "backlog": { + "type": "stdio", + "command": "backlog", + "args": ["mcp", "start"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index a88bf88..8a6b88d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,3 +7,11 @@ ## Next.js 15 route handlers - `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring. + +## Task tracking — Backlog.md + +- Project uses [Backlog.md](https://github.com/MrLesk/Backlog.md) for manual/human todos (CLI `backlog`, web UI on port 6420). +- MCP server is wired via `.mcp.json` — tools exposed under the `backlog` server. Use those tools instead of shelling out when possible. +- Tasks live as markdown under `backlog/tasks/`. Committed to git. Read them for context on outstanding manual work (env vars, IAM, infra) owed by humans. +- To start the web UI during development: `backlog browser` (port 6420, forwarded by devcontainer). +- Do NOT mirror Backlog.md tasks into Claude's internal todo system. Use one or the other — Backlog for durable cross-session work, internal todos for within-turn progress tracking. diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..66f9242 --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,14 @@ +project_name: "assessment-model" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +date_format: yyyy-mm-dd +max_column_width: 20 +auto_open_browser: false +default_port: 6420 +remote_operations: false +auto_commit: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - Add-BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME-to-staging-and-prod-env.md b/backlog/tasks/task-1 - Add-BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME-to-staging-and-prod-env.md new file mode 100644 index 0000000..b4938e5 --- /dev/null +++ b/backlog/tasks/task-1 - Add-BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME-to-staging-and-prod-env.md @@ -0,0 +1,26 @@ +--- +id: TASK-1 +title: Add BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME to staging and prod env +status: To Do +assignee: + - Jun-te Kim +created_date: '2026-04-18 19:01' +labels: + - env + - infra +dependencies: [] +priority: high +--- + +## Description + + +Dev .env.local has it; non-dev envs still missing. Combine route returns 500 'Server misconfiguration' without it. + + +## Acceptance Criteria + +- [ ] #1 Value set in staging env: bulk-address2uprn-combiner-queue-staging (or matching stage suffix) +- [ ] #2 Value set in prod env: bulk-address2uprn-combiner-queue-prod +- [ ] #3 Deploy redeployed; /api/portfolio/{pid}/bulk-uploads/{uid}/combine returns 200 not 500 + diff --git a/backlog/tasks/task-2 - Grant-sqs-SendMessage-IAM-on-combiner-queue-to-assessment-model-runtime.md b/backlog/tasks/task-2 - Grant-sqs-SendMessage-IAM-on-combiner-queue-to-assessment-model-runtime.md new file mode 100644 index 0000000..5ca196a --- /dev/null +++ b/backlog/tasks/task-2 - Grant-sqs-SendMessage-IAM-on-combiner-queue-to-assessment-model-runtime.md @@ -0,0 +1,25 @@ +--- +id: TASK-2 +title: 'Grant sqs:SendMessage IAM on combiner queue to assessment-model runtime' +status: To Do +assignee: + - Jun-te Kim +created_date: '2026-04-18 19:01' +labels: + - infra + - iam +dependencies: [] +priority: high +--- + +## Description + + +Combine route sends to bulk-address2uprn-combiner-queue-. Runtime role needs sqs:SendMessage + sqs:GetQueueUrl on that queue ARN. + + +## Acceptance Criteria + +- [ ] #1 IAM policy updated in terraform for staging + prod +- [ ] #2 Verified via AWS console or 'aws sqs get-queue-url' using runtime creds + diff --git a/backlog/tasks/task-3 - Deploy-bulk_address2uprn_combiner-Lambda-queue-via-terraform-to-staging-prod.md b/backlog/tasks/task-3 - Deploy-bulk_address2uprn_combiner-Lambda-queue-via-terraform-to-staging-prod.md new file mode 100644 index 0000000..fecfa88 --- /dev/null +++ b/backlog/tasks/task-3 - Deploy-bulk_address2uprn_combiner-Lambda-queue-via-terraform-to-staging-prod.md @@ -0,0 +1,26 @@ +--- +id: TASK-3 +title: Deploy bulk_address2uprn_combiner Lambda + queue via terraform to staging/prod +status: To Do +assignee: + - Jun-te Kim +created_date: '2026-04-18 19:01' +labels: + - infra + - terraform +dependencies: [] +priority: high +--- + +## Description + + +Lambda source at /workspaces/home/github/Model/backend/bulk_address2uprn_combiner/. Uses lambda_with_sqs module. Needs S3_BUCKET_NAME=retrofit_sap_data_bucket_name and DB creds envs. Confirm queue name convention bulk-address2uprn-combiner-queue-. + + +## Acceptance Criteria + +- [ ] #1 Lambda + queue exist in staging +- [ ] #2 Lambda + queue exist in prod +- [ ] #3 Lambda has read on ara_raw_outputs/ and write on bulk_final_outputs/ in retrofit_sap_data bucket + diff --git a/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md b/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md new file mode 100644 index 0000000..59a8215 --- /dev/null +++ b/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md @@ -0,0 +1,27 @@ +--- +id: TASK-4 +title: Smoke-test combiner end-to-end on dev +status: To Do +assignee: + - Jun-te Kim +created_date: '2026-04-18 19:02' +labels: + - qa + - bulk-upload +dependencies: [] +priority: medium +--- + +## Description + + +After env var + IAM ready, run a real bulk upload -> map columns -> onboard -> wait for terminal complete. Confirm combiner fires. + + +## Acceptance Criteria + +- [ ] #1 POST /combine returns 200 with {taskId, subTaskId} +- [ ] #2 CloudWatch for bulk_address2uprn_combiner shows the subtask picked up +- [ ] #3 bulk_final_outputs/{task_id}/combined_.csv exists in retrofit_sap_data bucket +- [ ] #4 bulk_address_uploads.combined_output_s3_uri populated for the test upload + diff --git a/backlog/tasks/task-5 - Squash-migrations-0178-DROP-0179-re-ADD-next-time-bulk_address_uploads-touched.md b/backlog/tasks/task-5 - Squash-migrations-0178-DROP-0179-re-ADD-next-time-bulk_address_uploads-touched.md new file mode 100644 index 0000000..2d64e04 --- /dev/null +++ b/backlog/tasks/task-5 - Squash-migrations-0178-DROP-0179-re-ADD-next-time-bulk_address_uploads-touched.md @@ -0,0 +1,28 @@ +--- +id: TASK-5 +title: >- + Squash migrations 0178 (DROP) + 0179 (re-ADD) next time bulk_address_uploads + touched +status: Done +assignee: + - Jun-te Kim +created_date: '2026-04-18 19:02' +updated_date: '2026-04-18 19:06' +labels: + - tech-debt + - db +dependencies: [] +priority: low +--- + +## Description + + +0178 DROPs task_id + combined_output_s3_uri; 0179 re-ADDs them. Net-zero on live, wasted churn on fresh envs. Collapse to single migration next time schema changes in this area. + + +## Implementation Notes + + +Squashed in-place: deleted 0179.sql + 0179_snapshot.json, removed from _journal.json, patched 0178_snapshot.json to include task_id + combined_output_s3_uri cols. Orphan row may remain in live __drizzle_migrations but drizzle tolerates it. + diff --git a/src/app/db/migrations/0179_mighty_cardiac.sql b/src/app/db/migrations/0179_mighty_cardiac.sql deleted file mode 100644 index 75889c2..0000000 --- a/src/app/db/migrations/0179_mighty_cardiac.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;--> statement-breakpoint -ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text; \ No newline at end of file diff --git a/src/app/db/migrations/meta/0178_snapshot.json b/src/app/db/migrations/meta/0178_snapshot.json index d380b0d..c793404 100644 --- a/src/app/db/migrations/meta/0178_snapshot.json +++ b/src/app/db/migrations/meta/0178_snapshot.json @@ -303,6 +303,18 @@ "primaryKey": false, "notNull": false }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "combined_output_s3_uri": { + "name": "combined_output_s3_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -6467,8 +6479,8 @@ "schema": "public", "values": [ "none", - "cladded with “sufficient space to fill the wall”", - "cladded with “insufficient space to fill the wall”" + "cladded with \u201csufficient space to fill the wall\u201d", + "cladded with \u201cinsufficient space to fill the wall\u201d" ] }, "public.inspections_insulation_material": { @@ -6495,8 +6507,8 @@ "schema": "public", "values": [ "no render", - "rendered with “insufficient” space between dpc and render", - "rendered with “sufficient” space between dpc and render" + "rendered with \u201cinsufficient\u201d space between dpc and render", + "rendered with \u201csufficient\u201d space between dpc and render" ] }, "public.inspections_roof_orientation": { @@ -6850,13 +6862,13 @@ "schema": "public", "values": [ "1", - "2–5", - "6–20", + "2\u20135", + "6\u201320", "21+", - "1–50", - "51–100", - "101–300", - "301–1000", + "1\u201350", + "51\u2013100", + "101\u2013300", + "301\u20131000", "1000+" ] }, diff --git a/src/app/db/migrations/meta/0179_snapshot.json b/src/app/db/migrations/meta/0179_snapshot.json deleted file mode 100644 index 69e16da..0000000 --- a/src/app/db/migrations/meta/0179_snapshot.json +++ /dev/null @@ -1,6910 +0,0 @@ -{ - "id": "351c4142-1926-4103-b56a-33f8530eafef", - "prevId": "eed32c53-4a51-451e-9898-5b2bd962bae7", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.postcode_search": { - "name": "postcode_search", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "postcode": { - "name": "postcode", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "result_data": { - "name": "result_data", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "last_updated_at": { - "name": "last_updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "postcode_search_postcode_unique": { - "name": "postcode_search_postcode_unique", - "nullsNotDistinct": false, - "columns": [ - "postcode" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.deal_measure_approval_events": { - "name": "deal_measure_approval_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "hubspot_deal_id": { - "name": "hubspot_deal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "measure_name": { - "name": "measure_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "acted_by": { - "name": "acted_by", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "acted_at": { - "name": "acted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_deal_measure_events_deal_id": { - "name": "idx_deal_measure_events_deal_id", - "columns": [ - { - "expression": "hubspot_deal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_deal_measure_events_acted_at": { - "name": "idx_deal_measure_events_acted_at", - "columns": [ - { - "expression": "acted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "deal_measure_approval_events_acted_by_user_id_fk": { - "name": "deal_measure_approval_events_acted_by_user_id_fk", - "tableFrom": "deal_measure_approval_events", - "tableTo": "user", - "columnsFrom": [ - "acted_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.deal_measure_approvals": { - "name": "deal_measure_approvals", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "hubspot_deal_id": { - "name": "hubspot_deal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "measure_name": { - "name": "measure_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_approved": { - "name": "is_approved", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "approved_by": { - "name": "approved_by", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "approved_at": { - "name": "approved_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_deal_measure_approvals_deal_id": { - "name": "idx_deal_measure_approvals_deal_id", - "columns": [ - { - "expression": "hubspot_deal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "deal_measure_approvals_approved_by_user_id_fk": { - "name": "deal_measure_approvals_approved_by_user_id_fk", - "tableFrom": "deal_measure_approvals", - "tableTo": "user", - "columnsFrom": [ - "approved_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "uq_deal_measure": { - "name": "uq_deal_measure", - "nullsNotDistinct": false, - "columns": [ - "hubspot_deal_id", - "measure_name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.bulk_address_uploads": { - "name": "bulk_address_uploads", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "s3_bucket": { - "name": "s3_bucket", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "s3_key": { - "name": "s3_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "filename": { - "name": "filename", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'ready_for_processing'" - }, - "source_headers": { - "name": "source_headers", - "type": "text[]", - "primaryKey": false, - "notNull": true, - "default": "'{}'" - }, - "column_mapping": { - "name": "column_mapping", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "task_id": { - "name": "task_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "combined_output_s3_uri": { - "name": "combined_output_s3_uri", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.aspect_condition": { - "name": "aspect_condition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "element_id": { - "name": "element_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "aspect_type": { - "name": "aspect_type", - "type": "aspect_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "aspect_instance": { - "name": "aspect_instance", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "quantity": { - "name": "quantity", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "install_date": { - "name": "install_date", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "renewal_year": { - "name": "renewal_year", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "comments": { - "name": "comments", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "aspect_condition_element_id_element_id_fk": { - "name": "aspect_condition_element_id_element_id_fk", - "tableFrom": "aspect_condition", - "tableTo": "element", - "columnsFrom": [ - "element_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.element": { - "name": "element", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "survey_id": { - "name": "survey_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "element_type": { - "name": "element_type", - "type": "element_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "element_instance": { - "name": "element_instance", - "type": "integer", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "element_survey_id_property_condition_survey_id_fk": { - "name": "element_survey_id_property_condition_survey_id_fk", - "tableFrom": "element", - "tableTo": "property_condition_survey", - "columnsFrom": [ - "survey_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.property_condition_survey": { - "name": "property_condition_survey", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "date": { - "name": "date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.hubspot_company_data": { - "name": "hubspot_company_data", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "company_id": { - "name": "company_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "company_name": { - "name": "company_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.hubspot_deal_data": { - "name": "hubspot_deal_data", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "deal_id": { - "name": "deal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "dealname": { - "name": "dealname", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "dealstage": { - "name": "dealstage", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "company_id": { - "name": "company_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "project_code": { - "name": "project_code", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "landlord_property_id": { - "name": "landlord_property_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "listing_id": { - "name": "listing_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "uprn": { - "name": "uprn", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "outcome": { - "name": "outcome", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "outcome_notes": { - "name": "outcome_notes", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "major_condition_issue_description": { - "name": "major_condition_issue_description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "major_condition_issue_photos": { - "name": "major_condition_issue_photos", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "major_condition_issue_evidence_s3_url": { - "name": "major_condition_issue_evidence_s3_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "coordination_status": { - "name": "coordination_status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "design_status": { - "name": "design_status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pashub_link": { - "name": "pashub_link", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sharepoint_link": { - "name": "sharepoint_link", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "dampmould_growth": { - "name": "dampmould_growth", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pre_sap": { - "name": "pre_sap", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "coordinator": { - "name": "coordinator", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "mtp_completion_date": { - "name": "mtp_completion_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "mtp_re_model_completion_date": { - "name": "mtp_re_model_completion_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "ioe_v3_completion_date": { - "name": "ioe_v3_completion_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "proposed_measures": { - "name": "proposed_measures", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "approved_package": { - "name": "approved_package", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "designer": { - "name": "designer", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "design_type": { - "name": "design_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "design_completion_date": { - "name": "design_completion_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "actual_measures_installed": { - "name": "actual_measures_installed", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "installer": { - "name": "installer", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "installer_handover": { - "name": "installer_handover", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lodgement_status": { - "name": "lodgement_status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "measures_lodgement_date": { - "name": "measures_lodgement_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "lodgement_date": { - "name": "lodgement_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "expected_commencement_date": { - "name": "expected_commencement_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "coordination_comments": { - "name": "coordination_comments", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "surveyor": { - "name": "surveyor", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "damp_mould_and_repairs_comments": { - "name": "damp_mould_and_repairs_comments", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "confirmed_survey_date": { - "name": "confirmed_survey_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "confirmed_survey_time": { - "name": "confirmed_survey_time", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "surveyed_date": { - "name": "surveyed_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.property_status_tracker": { - "name": "property_status_tracker", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "hubspot_deal_id": { - "name": "hubspot_deal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "property_id": { - "name": "property_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "property_status_tracker_property_id_property_id_fk": { - "name": "property_status_tracker_property_id_property_id_fk", - "tableFrom": "property_status_tracker", - "tableTo": "property", - "columnsFrom": [ - "property_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "property_status_tracker_portfolio_id_portfolio_id_fk": { - "name": "property_status_tracker_portfolio_id_portfolio_id_fk", - "tableFrom": "property_status_tracker", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.energy_assessments": { - "name": "energy_assessments", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "uprn_source": { - "name": "uprn_source", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "property_type": { - "name": "property_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "building_reference_number": { - "name": "building_reference_number", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "current_energy_efficiency": { - "name": "current_energy_efficiency", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "current_energy_rating": { - "name": "current_energy_rating", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "address1": { - "name": "address1", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "address2": { - "name": "address2", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "address3": { - "name": "address3", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "posttown": { - "name": "posttown", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "postcode": { - "name": "postcode", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "county": { - "name": "county", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "constituency": { - "name": "constituency", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "constituency_label": { - "name": "constituency_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "low_energy_fixed_light_count": { - "name": "low_energy_fixed_light_count", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "construction_age_band": { - "name": "construction_age_band", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mainheat_energy_eff": { - "name": "mainheat_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "windows_env_eff": { - "name": "windows_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "lighting_energy_eff": { - "name": "lighting_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "environment_impact_potential": { - "name": "environment_impact_potential", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mainheatcont_description": { - "name": "mainheatcont_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sheating_energy_eff": { - "name": "sheating_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "local_authority": { - "name": "local_authority", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "local_authority_label": { - "name": "local_authority_label", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "fixed_lighting_outlets_count": { - "name": "fixed_lighting_outlets_count", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "energy_tariff": { - "name": "energy_tariff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mechanical_ventilation": { - "name": "mechanical_ventilation", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "solar_water_heating_flag": { - "name": "solar_water_heating_flag", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "co2_emissions_potential": { - "name": "co2_emissions_potential", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "number_heated_rooms": { - "name": "number_heated_rooms", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "floor_description": { - "name": "floor_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "energy_consumption_potential": { - "name": "energy_consumption_potential", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "built_form": { - "name": "built_form", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "number_open_fireplaces": { - "name": "number_open_fireplaces", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "windows_description": { - "name": "windows_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "glazed_area": { - "name": "glazed_area", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "inspection_date": { - "name": "inspection_date", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true - }, - "mains_gas_flag": { - "name": "mains_gas_flag", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "co2_emiss_curr_per_floor_area": { - "name": "co2_emiss_curr_per_floor_area", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "heat_loss_corridor": { - "name": "heat_loss_corridor", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "unheated_corridor_length": { - "name": "unheated_corridor_length", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "flat_storey_count": { - "name": "flat_storey_count", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "roof_energy_eff": { - "name": "roof_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "total_floor_area": { - "name": "total_floor_area", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "environment_impact_current": { - "name": "environment_impact_current", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "roof_description": { - "name": "roof_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "floor_energy_eff": { - "name": "floor_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "number_habitable_rooms": { - "name": "number_habitable_rooms", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "hot_water_env_eff": { - "name": "hot_water_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mainheatc_energy_eff": { - "name": "mainheatc_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "main_fuel": { - "name": "main_fuel", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "lighting_env_eff": { - "name": "lighting_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "windows_energy_eff": { - "name": "windows_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "floor_env_eff": { - "name": "floor_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sheating_env_eff": { - "name": "sheating_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "lighting_description": { - "name": "lighting_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "roof_env_eff": { - "name": "roof_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "walls_energy_eff": { - "name": "walls_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "photo_supply": { - "name": "photo_supply", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "lighting_cost_potential": { - "name": "lighting_cost_potential", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mainheat_env_eff": { - "name": "mainheat_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "multi_glaze_proportion": { - "name": "multi_glaze_proportion", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "main_heating_controls": { - "name": "main_heating_controls", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "flat_top_storey": { - "name": "flat_top_storey", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secondheat_description": { - "name": "secondheat_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "walls_env_eff": { - "name": "walls_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "transaction_type": { - "name": "transaction_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "extension_count": { - "name": "extension_count", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mainheatc_env_eff": { - "name": "mainheatc_env_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "lmk_key": { - "name": "lmk_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "wind_turbine_count": { - "name": "wind_turbine_count", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tenure": { - "name": "tenure", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "floor_level": { - "name": "floor_level", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "potential_energy_efficiency": { - "name": "potential_energy_efficiency", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "potential_energy_rating": { - "name": "potential_energy_rating", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "hot_water_energy_eff": { - "name": "hot_water_energy_eff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "low_energy_lighting": { - "name": "low_energy_lighting", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "walls_description": { - "name": "walls_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "hotwater_description": { - "name": "hotwater_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "co2_emissions_current": { - "name": "co2_emissions_current", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "heating_cost_current": { - "name": "heating_cost_current", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "heating_cost_potential": { - "name": "heating_cost_potential", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "hot_water_cost_current": { - "name": "hot_water_cost_current", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "hot_water_cost_potential": { - "name": "hot_water_cost_potential", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "lighting_cost_current": { - "name": "lighting_cost_current", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "energy_consumption_current": { - "name": "energy_consumption_current", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "lodgement_date": { - "name": "lodgement_date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "lodgement_datetime": { - "name": "lodgement_datetime", - "type": "timestamp (6)", - "primaryKey": false, - "notNull": true - }, - "mainheat_description": { - "name": "mainheat_description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "floor_height": { - "name": "floor_height", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "glazed_type": { - "name": "glazed_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "file_location": { - "name": "file_location", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "surveyor_name": { - "name": "surveyor_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "surveyor_company": { - "name": "surveyor_company", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "space_heating_kwh": { - "name": "space_heating_kwh", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "water_heating_kwh": { - "name": "water_heating_kwh", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "number_of_doors": { - "name": "number_of_doors", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "number_of_insulated_doors": { - "name": "number_of_insulated_doors", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "number_of_floors": { - "name": "number_of_floors", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "insulation_wall_area": { - "name": "insulation_wall_area", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "heat_loss_perimeter": { - "name": "heat_loss_perimeter", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "party_wall_length": { - "name": "party_wall_length", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "perimeter": { - "name": "perimeter", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "rooms_with_bath_and_or_shower": { - "name": "rooms_with_bath_and_or_shower", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "rooms_with_mixer_shower_no_bath": { - "name": "rooms_with_mixer_shower_no_bath", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "room_with_bath_and_mixer_shower": { - "name": "room_with_bath_and_mixer_shower", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "percent_draftproofed": { - "name": "percent_draftproofed", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "has_hot_water_cylinder": { - "name": "has_hot_water_cylinder", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "cylinder_insulation_type": { - "name": "cylinder_insulation_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cylinder_insulation_thickness": { - "name": "cylinder_insulation_thickness", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "cylinder_thermostat": { - "name": "cylinder_thermostat", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "main_dwelling_ground_floor_area": { - "name": "main_dwelling_ground_floor_area", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "number_of_windows": { - "name": "number_of_windows", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "windows_area": { - "name": "windows_area", - "type": "real", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.energy_assessment_documents": { - "name": "energy_assessment_documents", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "energy_assessment_id": { - "name": "energy_assessment_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "document_type": { - "name": "document_type", - "type": "document_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "document_location": { - "name": "document_location", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "scenario_id": { - "name": "scenario_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk": { - "name": "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk", - "tableFrom": "energy_assessment_documents", - "tableTo": "energy_assessments", - "columnsFrom": [ - "energy_assessment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk": { - "name": "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk", - "tableFrom": "energy_assessment_documents", - "tableTo": "energy_assessment_scenarios", - "columnsFrom": [ - "scenario_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.energy_assessment_scenarios": { - "name": "energy_assessment_scenarios", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "scenario_name": { - "name": "scenario_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "energy_assessment_id": { - "name": "energy_assessment_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk": { - "name": "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk", - "tableFrom": "energy_assessment_scenarios", - "tableTo": "energy_assessments", - "columnsFrom": [ - "energy_assessment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.epc_store": { - "name": "epc_store", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "epc_api_created_at": { - "name": "epc_api_created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "epc_api": { - "name": "epc_api", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "epc_page_created_at": { - "name": "epc_page_created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "epc_page": { - "name": "epc_page", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "epc_page_rrn": { - "name": "epc_page_rrn", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uq_epc_store_uprn": { - "name": "uq_epc_store_uprn", - "columns": [ - { - "expression": "uprn", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.files_from_surveyor": { - "name": "files_from_surveyor", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "property_id": { - "name": "property_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "s3_json_url": { - "name": "s3_json_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "files_from_surveyor_portfolio_id_portfolio_id_fk": { - "name": "files_from_surveyor_portfolio_id_portfolio_id_fk", - "tableFrom": "files_from_surveyor", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "files_from_surveyor_property_id_property_id_fk": { - "name": "files_from_surveyor_property_id_property_id_fk", - "tableFrom": "files_from_surveyor", - "tableTo": "property", - "columnsFrom": [ - "property_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.funding_package": { - "name": "funding_package", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "plan_id": { - "name": "plan_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "scheme": { - "name": "scheme", - "type": "scheme", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "project_funding": { - "name": "project_funding", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "total_uplift": { - "name": "total_uplift", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "full_project_score": { - "name": "full_project_score", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "partial_project_score": { - "name": "partial_project_score", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "uplift_project_score": { - "name": "uplift_project_score", - "type": "real", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "funding_package_plan_id_plan_id_fk": { - "name": "funding_package_plan_id_plan_id_fk", - "tableFrom": "funding_package", - "tableTo": "plan", - "columnsFrom": [ - "plan_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.funding_package_measures": { - "name": "funding_package_measures", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "funding_package_id": { - "name": "funding_package_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "measure": { - "name": "measure", - "type": "type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "material_id": { - "name": "material_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "innovation_uplift": { - "name": "innovation_uplift", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "partial_project_score": { - "name": "partial_project_score", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "uplift_project_score": { - "name": "uplift_project_score", - "type": "real", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "funding_package_measures_funding_package_id_funding_package_id_fk": { - "name": "funding_package_measures_funding_package_id_funding_package_id_fk", - "tableFrom": "funding_package_measures", - "tableTo": "funding_package", - "columnsFrom": [ - "funding_package_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "funding_package_measures_material_id_material_id_fk": { - "name": "funding_package_measures_material_id_material_id_fk", - "tableFrom": "funding_package_measures", - "tableTo": "material", - "columnsFrom": [ - "material_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.inspections": { - "name": "inspections", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "property_id": { - "name": "property_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "archetype": { - "name": "archetype", - "type": "inspection_archetype", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "archetype_2": { - "name": "archetype_2", - "type": "inspection_archetype_2", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "wall_construction": { - "name": "wall_construction", - "type": "inspections_wall_construction", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "insulation": { - "name": "insulation", - "type": "inspections_wall_insulation", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "insulation_material": { - "name": "insulation_material", - "type": "inspections_insulation_material", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "borescoped": { - "name": "borescoped", - "type": "inspection_borescoped", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "roof_orientation": { - "name": "roof_orientation", - "type": "inspections_roof_orientation", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "tile_hung": { - "name": "tile_hung", - "type": "inspections_tile_hung", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "rendered": { - "name": "rendered", - "type": "inspections_rendered", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "cladding": { - "name": "cladding", - "type": "inspections_cladding", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "access_issues": { - "name": "access_issues", - "type": "inspections_access_issues", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "notes": { - "name": "notes", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "surveyor_name": { - "name": "surveyor_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "inspections_property_id_property_id_fk": { - "name": "inspections_property_id_property_id_fk", - "tableFrom": "inspections", - "tableTo": "property", - "columnsFrom": [ - "property_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.material": { - "name": "material", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "type": { - "name": "type", - "type": "type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "depth": { - "name": "depth", - "type": "json", - "primaryKey": false, - "notNull": false - }, - "depth_unit": { - "name": "depth_unit", - "type": "depth_unit", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "cost_unit": { - "name": "cost_unit", - "type": "cost_unit", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "r_value_per_mm": { - "name": "r_value_per_mm", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "r_value_unit": { - "name": "r_value_unit", - "type": "r_value_unit", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "thermal_conductivity": { - "name": "thermal_conductivity", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "thermal_conductivity_unit": { - "name": "thermal_conductivity_unit", - "type": "thermal_conductivity_unit", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "link": { - "name": "link", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "prime_material_cost": { - "name": "prime_material_cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "material_cost": { - "name": "material_cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "labour_cost": { - "name": "labour_cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "labour_hours_per_unit": { - "name": "labour_hours_per_unit", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "plant_cost": { - "name": "plant_cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "total_cost": { - "name": "total_cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "cost": { - "name": "cost", - "type": "json", - "primaryKey": false, - "notNull": false - }, - "notes": { - "name": "notes", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_installer_quote": { - "name": "is_installer_quote", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "innovation_rate": { - "name": "innovation_rate", - "type": "real", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "size": { - "name": "size", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "size_unit": { - "name": "size_unit", - "type": "size_unit", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "includes_scaffolding": { - "name": "includes_scaffolding", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "includes_battery": { - "name": "includes_battery", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "battery_size": { - "name": "battery_size", - "type": "real", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organisation": { - "name": "organisation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "hubspot_company_id": { - "name": "hubspot_company_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.portfolio_organisation": { - "name": "portfolio_organisation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "organisation_id": { - "name": "organisation_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "portfolio_organisation_portfolio_id_portfolio_id_fk": { - "name": "portfolio_organisation_portfolio_id_portfolio_id_fk", - "tableFrom": "portfolio_organisation", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "portfolio_organisation_organisation_id_organisation_id_fk": { - "name": "portfolio_organisation_organisation_id_organisation_id_fk", - "tableFrom": "portfolio_organisation", - "tableTo": "organisation", - "columnsFrom": [ - "organisation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "portfolio_organisation_portfolio_id_unique": { - "name": "portfolio_organisation_portfolio_id_unique", - "nullsNotDistinct": false, - "columns": [ - "portfolio_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.portfolio": { - "name": "portfolio", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "budget": { - "name": "budget", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "goal": { - "name": "goal", - "type": "goal", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "cost": { - "name": "cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "number_of_properties": { - "name": "number_of_properties", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "co2_equivalent_savings": { - "name": "co2_equivalent_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "energy_savings": { - "name": "energy_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "energy_cost_savings": { - "name": "energy_cost_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "property_valuation_increase": { - "name": "property_valuation_increase", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "rental_yield_increase": { - "name": "rental_yield_increase", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "total_work_hours": { - "name": "total_work_hours", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "labour_days": { - "name": "labour_days", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "epc_breakdown_pre_retrofit": { - "name": "epc_breakdown_pre_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "epc_breakdown_post_retrofit": { - "name": "epc_breakdown_post_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "n_units_to_retrofit": { - "name": "n_units_to_retrofit", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "co2_per_unit_pre_retrofit": { - "name": "co2_per_unit_pre_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "co2_per_unit_post_retrofit": { - "name": "co2_per_unit_post_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "energy_bill_per_unit_pre_retrofit": { - "name": "energy_bill_per_unit_pre_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "energy_bill_per_unit_post_retrofit": { - "name": "energy_bill_per_unit_post_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "energy_consumption_per_unit_pre_retrofit": { - "name": "energy_consumption_per_unit_pre_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "energy_consumption_per_unit_post_retrofit": { - "name": "energy_consumption_per_unit_post_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "valuation_improvement_per_unit": { - "name": "valuation_improvement_per_unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_per_unit": { - "name": "cost_per_unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_per_co2_saved": { - "name": "cost_per_co2_saved", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_per_sap_point": { - "name": "cost_per_sap_point", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "valuation_return_on_investment": { - "name": "valuation_return_on_investment", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.portfolio_capabilities": { - "name": "portfolio_capabilities", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "capability": { - "name": "capability", - "type": "portfolio_capability", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "portfolio_capabilities_user_id_user_id_fk": { - "name": "portfolio_capabilities_user_id_user_id_fk", - "tableFrom": "portfolio_capabilities", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "portfolio_capabilities_portfolio_id_portfolio_id_fk": { - "name": "portfolio_capabilities_portfolio_id_portfolio_id_fk", - "tableFrom": "portfolio_capabilities", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "portfolio_capabilities_user_id_portfolio_id_capability_unique": { - "name": "portfolio_capabilities_user_id_portfolio_id_capability_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id", - "portfolio_id", - "capability" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.portfolioUsers": { - "name": "portfolioUsers", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "portfolioUsers_user_id_user_id_fk": { - "name": "portfolioUsers_user_id_user_id_fk", - "tableFrom": "portfolioUsers", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "portfolioUsers_portfolio_id_portfolio_id_fk": { - "name": "portfolioUsers_portfolio_id_portfolio_id_fk", - "tableFrom": "portfolioUsers", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.non_intrusive_survey": { - "name": "non_intrusive_survey", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "survey_date": { - "name": "survey_date", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "surveyor": { - "name": "surveyor", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.non_intrusive_survey_notes": { - "name": "non_intrusive_survey_notes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "survey_id": { - "name": "survey_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "note": { - "name": "note", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk": { - "name": "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk", - "tableFrom": "non_intrusive_survey_notes", - "tableTo": "non_intrusive_survey", - "columnsFrom": [ - "survey_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.property": { - "name": "property", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "creation_status": { - "name": "creation_status", - "type": "creation_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "landlord_property_id": { - "name": "landlord_property_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "building_reference_number": { - "name": "building_reference_number", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "postcode": { - "name": "postcode", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "has_pre_condition_report": { - "name": "has_pre_condition_report", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "has_recommendations": { - "name": "has_recommendations", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "property_type": { - "name": "property_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "built_form": { - "name": "built_form", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "local_authority": { - "name": "local_authority", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "constituency": { - "name": "constituency", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "number_of_rooms": { - "name": "number_of_rooms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "year_built": { - "name": "year_built", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tenure": { - "name": "tenure", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "current_epc_rating": { - "name": "current_epc_rating", - "type": "epc", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "current_sap_points": { - "name": "current_sap_points", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "current_valuation": { - "name": "current_valuation", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "installed_measures_sap_point_adjustment": { - "name": "installed_measures_sap_point_adjustment", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "is_sap_points_adjusted_for_installed_measures": { - "name": "is_sap_points_adjusted_for_installed_measures", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "original_sap_points": { - "name": "original_sap_points", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "lodged_sap_points": { - "name": "lodged_sap_points", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "lodged_epc_rating": { - "name": "lodged_epc_rating", - "type": "epc", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uq_property_portfolio_uprn": { - "name": "uq_property_portfolio_uprn", - "columns": [ - { - "expression": "portfolio_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "uprn", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"property\".\"uprn\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "property_portfolio_id_portfolio_id_fk": { - "name": "property_portfolio_id_portfolio_id_fk", - "tableFrom": "property", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.property_details_epc": { - "name": "property_details_epc", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "property_id": { - "name": "property_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "full_address": { - "name": "full_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lodgement_date": { - "name": "lodgement_date", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "is_expired": { - "name": "is_expired", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "total_floor_area": { - "name": "total_floor_area", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "walls": { - "name": "walls", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "walls_rating": { - "name": "walls_rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "roof": { - "name": "roof", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "roof_rating": { - "name": "roof_rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "floor": { - "name": "floor", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "floor_rating": { - "name": "floor_rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "windows": { - "name": "windows", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "windows_rating": { - "name": "windows_rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "heating": { - "name": "heating", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "heating_rating": { - "name": "heating_rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "heating_controls": { - "name": "heating_controls", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "heating_controls_rating": { - "name": "heating_controls_rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "hot_water": { - "name": "hot_water", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "hot_water_rating": { - "name": "hot_water_rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "lighting": { - "name": "lighting", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lighting_rating": { - "name": "lighting_rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "mainfuel": { - "name": "mainfuel", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ventilation": { - "name": "ventilation", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "solar_pv": { - "name": "solar_pv", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "solar_hot_water": { - "name": "solar_hot_water", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "wind_turbine": { - "name": "wind_turbine", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "floor_height": { - "name": "floor_height", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "number_heated_rooms": { - "name": "number_heated_rooms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "heat_loss_corridor": { - "name": "heat_loss_corridor", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "unheated_corridor_length": { - "name": "unheated_corridor_length", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "number_of_open_fireplaces": { - "name": "number_of_open_fireplaces", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "number_of_extensions": { - "name": "number_of_extensions", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "number_of_storeys": { - "name": "number_of_storeys", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "mains_gas": { - "name": "mains_gas", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "energy_tariff": { - "name": "energy_tariff", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "primary_energy_consumption": { - "name": "primary_energy_consumption", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "co2_emissions": { - "name": "co2_emissions", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "current_energy_demand": { - "name": "current_energy_demand", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "current_energy_demand_heating_hotwater": { - "name": "current_energy_demand_heating_hotwater", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "estimated": { - "name": "estimated", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "sap_05_overwritten": { - "name": "sap_05_overwritten", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "sap_05_score": { - "name": "sap_05_score", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "sap_05_epc_rating": { - "name": "sap_05_epc_rating", - "type": "epc", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "heating_cost_current": { - "name": "heating_cost_current", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "hot_water_cost_current": { - "name": "hot_water_cost_current", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "lighting_cost_current": { - "name": "lighting_cost_current", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "appliances_cost_current": { - "name": "appliances_cost_current", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "gas_standing_charge": { - "name": "gas_standing_charge", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "electricity_standing_charge": { - "name": "electricity_standing_charge", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "original_co2_emissions": { - "name": "original_co2_emissions", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "original_primary_energy_consumption": { - "name": "original_primary_energy_consumption", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "original_current_energy_demand": { - "name": "original_current_energy_demand", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "original_current_energy_demand_heating_hotwater": { - "name": "original_current_energy_demand_heating_hotwater", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "installed_measures_co2_adjustment": { - "name": "installed_measures_co2_adjustment", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "installed_measures_energy_demand_adjustment": { - "name": "installed_measures_energy_demand_adjustment", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "installed_measures_total_energy_bill_adjustment": { - "name": "installed_measures_total_energy_bill_adjustment", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "installed_measures_heat_demand_adjustment": { - "name": "installed_measures_heat_demand_adjustment", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "is_epc_adjusted_for_installed_measures": { - "name": "is_epc_adjusted_for_installed_measures", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "lodged_co2_emissions": { - "name": "lodged_co2_emissions", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "lodged_heat_demand": { - "name": "lodged_heat_demand", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "has_been_remodelled": { - "name": "has_been_remodelled", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "environment_impact_current": { - "name": "environment_impact_current", - "type": "real", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uq_property_details_epc_property_portfolio": { - "name": "uq_property_details_epc_property_portfolio", - "columns": [ - { - "expression": "property_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "portfolio_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "property_details_epc_property_id_property_id_fk": { - "name": "property_details_epc_property_id_property_id_fk", - "tableFrom": "property_details_epc", - "tableTo": "property", - "columnsFrom": [ - "property_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "property_details_epc_portfolio_id_portfolio_id_fk": { - "name": "property_details_epc_portfolio_id_portfolio_id_fk", - "tableFrom": "property_details_epc", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.property_details_meter": { - "name": "property_details_meter", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "energy_supplier": { - "name": "energy_supplier", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gas_supplier": { - "name": "gas_supplier", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "meter_reading_total": { - "name": "meter_reading_total", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "meter_reading_electricity": { - "name": "meter_reading_electricity", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "meter_reading_gas": { - "name": "meter_reading_gas", - "type": "real", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.property_details_spatial": { - "name": "property_details_spatial", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "x_coordinate": { - "name": "x_coordinate", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "y_coordinate": { - "name": "y_coordinate", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "latitude": { - "name": "latitude", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "longitude": { - "name": "longitude", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "conservation_status": { - "name": "conservation_status", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "is_listed_building": { - "name": "is_listed_building", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "is_heritage_building": { - "name": "is_heritage_building", - "type": "boolean", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uq_property_details_spatial_uprn": { - "name": "uq_property_details_spatial_uprn", - "columns": [ - { - "expression": "uprn", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.property_targets": { - "name": "property_targets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "property_id": { - "name": "property_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "epc": { - "name": "epc", - "type": "epc", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "heat_demand": { - "name": "heat_demand", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "property_targets_property_id_property_id_fk": { - "name": "property_targets_property_id_property_id_fk", - "tableFrom": "property_targets", - "tableTo": "property", - "columnsFrom": [ - "property_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "property_targets_portfolio_id_portfolio_id_fk": { - "name": "property_targets_portfolio_id_portfolio_id_fk", - "tableFrom": "property_targets", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.installed_measure": { - "name": "installed_measure", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "measure_type": { - "name": "measure_type", - "type": "measure_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "installed_at": { - "name": "installed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "sap_points": { - "name": "sap_points", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "carbon_savings": { - "name": "carbon_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "kwh_savings": { - "name": "kwh_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "bill_savings": { - "name": "bill_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "heat_demand_savings": { - "name": "heat_demand_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - } - }, - "indexes": { - "idx_installed_measure_uprn": { - "name": "idx_installed_measure_uprn", - "columns": [ - { - "expression": "uprn", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_installed_measure_uprn_active": { - "name": "idx_installed_measure_uprn_active", - "columns": [ - { - "expression": "uprn", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"installed_measure\".\"is_active\" = true", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_installed_measure_measure_type": { - "name": "idx_installed_measure_measure_type", - "columns": [ - { - "expression": "measure_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_installed_measure_uprn_measure": { - "name": "idx_installed_measure_uprn_measure", - "columns": [ - { - "expression": "uprn", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "measure_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"installed_measure\".\"is_active\" = true", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.plan": { - "name": "plan", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "property_id": { - "name": "property_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "scenario_id": { - "name": "scenario_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "is_default": { - "name": "is_default", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "valuation_increase_lower_bound": { - "name": "valuation_increase_lower_bound", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "valuation_increase_upper_bound": { - "name": "valuation_increase_upper_bound", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "valuation_increase_average": { - "name": "valuation_increase_average", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "post_sap_points": { - "name": "post_sap_points", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "post_epc_rating": { - "name": "post_epc_rating", - "type": "epc", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "post_co2_emissions": { - "name": "post_co2_emissions", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "co2_savings": { - "name": "co2_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "post_energy_bill": { - "name": "post_energy_bill", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "energy_bill_savings": { - "name": "energy_bill_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "post_energy_consumption": { - "name": "post_energy_consumption", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "energy_consumption_savings": { - "name": "energy_consumption_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "valuation_post_retrofit": { - "name": "valuation_post_retrofit", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "valuation_increase": { - "name": "valuation_increase", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "cost_of_works": { - "name": "cost_of_works", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "contingency_cost": { - "name": "contingency_cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "plan_type": { - "name": "plan_type", - "type": "plan_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_plan_portfolio_scenario": { - "name": "idx_plan_portfolio_scenario", - "columns": [ - { - "expression": "portfolio_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scenario_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_plan_latest_per_property": { - "name": "idx_plan_latest_per_property", - "columns": [ - { - "expression": "portfolio_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scenario_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "property_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "plan_portfolio_id_portfolio_id_fk": { - "name": "plan_portfolio_id_portfolio_id_fk", - "tableFrom": "plan", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "plan_property_id_property_id_fk": { - "name": "plan_property_id_property_id_fk", - "tableFrom": "plan", - "tableTo": "property", - "columnsFrom": [ - "property_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "plan_scenario_id_scenario_id_fk": { - "name": "plan_scenario_id_scenario_id_fk", - "tableFrom": "plan", - "tableTo": "scenario", - "columnsFrom": [ - "scenario_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.plan_recommendations": { - "name": "plan_recommendations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "plan_id": { - "name": "plan_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "recommendation_id": { - "name": "recommendation_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "idx_plan_recommendations_plan_id": { - "name": "idx_plan_recommendations_plan_id", - "columns": [ - { - "expression": "plan_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_plan_recommendations_plan_rec": { - "name": "idx_plan_recommendations_plan_rec", - "columns": [ - { - "expression": "plan_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "recommendation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "plan_recommendations_plan_id_plan_id_fk": { - "name": "plan_recommendations_plan_id_plan_id_fk", - "tableFrom": "plan_recommendations", - "tableTo": "plan", - "columnsFrom": [ - "plan_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "plan_recommendations_recommendation_id_recommendation_id_fk": { - "name": "plan_recommendations_recommendation_id_recommendation_id_fk", - "tableFrom": "plan_recommendations", - "tableTo": "recommendation", - "columnsFrom": [ - "recommendation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.recommendation": { - "name": "recommendation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "property_id": { - "name": "property_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "measure_type": { - "name": "measure_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "estimated_cost": { - "name": "estimated_cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "contingency_cost": { - "name": "contingency_cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "default": { - "name": "default", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "starting_u_value": { - "name": "starting_u_value", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "new_u_value": { - "name": "new_u_value", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "sap_points": { - "name": "sap_points", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "heat_demand": { - "name": "heat_demand", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "kwh_savings": { - "name": "kwh_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "co2_equivalent_savings": { - "name": "co2_equivalent_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "energy_savings": { - "name": "energy_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "energy_cost_savings": { - "name": "energy_cost_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "property_valuation_increase": { - "name": "property_valuation_increase", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "rental_yield_increase": { - "name": "rental_yield_increase", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "total_work_hours": { - "name": "total_work_hours", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "labour_days": { - "name": "labour_days", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "already_installed": { - "name": "already_installed", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - } - }, - "indexes": { - "recommendation_property_id_idx": { - "name": "recommendation_property_id_idx", - "columns": [ - { - "expression": "property_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_recommendation_active_defaults": { - "name": "idx_recommendation_active_defaults", - "columns": [ - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"recommendation\".\"default\" = true AND \"recommendation\".\"already_installed\" = false", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_recommendation_active_id_property": { - "name": "idx_recommendation_active_id_property", - "columns": [ - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "property_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"recommendation\".\"default\" = true AND \"recommendation\".\"already_installed\" = false", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "recommendation_property_id_property_id_fk": { - "name": "recommendation_property_id_property_id_fk", - "tableFrom": "recommendation", - "tableTo": "property", - "columnsFrom": [ - "property_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.recommendation_materials": { - "name": "recommendation_materials", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "recommendation_id": { - "name": "recommendation_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "material_id": { - "name": "material_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "depth": { - "name": "depth", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "quantity": { - "name": "quantity", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "quantity_unit": { - "name": "quantity_unit", - "type": "unit_quantity", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "estimated_cost": { - "name": "estimated_cost", - "type": "real", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "recommendation_materials_recommendation_id_idx": { - "name": "recommendation_materials_recommendation_id_idx", - "columns": [ - { - "expression": "recommendation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "recommendation_materials_recommendation_id_recommendation_id_fk": { - "name": "recommendation_materials_recommendation_id_recommendation_id_fk", - "tableFrom": "recommendation_materials", - "tableTo": "recommendation", - "columnsFrom": [ - "recommendation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "recommendation_materials_material_id_material_id_fk": { - "name": "recommendation_materials_material_id_material_id_fk", - "tableFrom": "recommendation_materials", - "tableTo": "material", - "columnsFrom": [ - "material_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.scenario": { - "name": "scenario", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "budget": { - "name": "budget", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "housing_type": { - "name": "housing_type", - "type": "housing_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "goal": { - "name": "goal", - "type": "goal", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "goal_value": { - "name": "goal_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ashp_cop": { - "name": "ashp_cop", - "type": "real", - "primaryKey": false, - "notNull": false, - "default": 2.8 - }, - "trigger_file_path": { - "name": "trigger_file_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "already_installed_file_path": { - "name": "already_installed_file_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "patches_file_path": { - "name": "patches_file_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "non_invasive_recommendations_file_path": { - "name": "non_invasive_recommendations_file_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "exclusions": { - "name": "exclusions", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "multi_plan": { - "name": "multi_plan", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "is_default": { - "name": "is_default", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "cost": { - "name": "cost", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "contingency": { - "name": "contingency", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "funding": { - "name": "funding", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "total_work_hours": { - "name": "total_work_hours", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "energy_savings": { - "name": "energy_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "co2_equivalent_savings": { - "name": "co2_equivalent_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "energy_cost_savings": { - "name": "energy_cost_savings", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "property_valuation_increase": { - "name": "property_valuation_increase", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "labour_days": { - "name": "labour_days", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "epc_breakdown_pre_retrofit": { - "name": "epc_breakdown_pre_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "epc_breakdown_post_retrofit": { - "name": "epc_breakdown_post_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "number_of_properties": { - "name": "number_of_properties", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "n_units_to_retrofit": { - "name": "n_units_to_retrofit", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "co2_per_unit_pre_retrofit": { - "name": "co2_per_unit_pre_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "co2_per_unit_post_retrofit": { - "name": "co2_per_unit_post_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "energy_bill_per_unit_pre_retrofit": { - "name": "energy_bill_per_unit_pre_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "energy_bill_per_unit_post_retrofit": { - "name": "energy_bill_per_unit_post_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "energy_consumption_per_unit_pre_retrofit": { - "name": "energy_consumption_per_unit_pre_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "energy_consumption_per_unit_post_retrofit": { - "name": "energy_consumption_per_unit_post_retrofit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "valuation_improvement_per_unit": { - "name": "valuation_improvement_per_unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_per_unit": { - "name": "cost_per_unit", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_per_co2_saved": { - "name": "cost_per_co2_saved", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_per_sap_point": { - "name": "cost_per_sap_point", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "valuation_return_on_investment": { - "name": "valuation_return_on_investment", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "scenario_portfolio_id_portfolio_id_fk": { - "name": "scenario_portfolio_id_portfolio_id_fk", - "tableFrom": "scenario", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.property_removal_requests": { - "name": "property_removal_requests", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "hubspot_deal_id": { - "name": "hubspot_deal_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "reason": { - "name": "reason", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "requested_by": { - "name": "requested_by", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "requested_at": { - "name": "requested_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "reviewed_by": { - "name": "reviewed_by", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "reviewed_at": { - "name": "reviewed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_removal_requests_deal_id": { - "name": "idx_removal_requests_deal_id", - "columns": [ - { - "expression": "hubspot_deal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_removal_requests_portfolio_id": { - "name": "idx_removal_requests_portfolio_id", - "columns": [ - { - "expression": "portfolio_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "property_removal_requests_portfolio_id_portfolio_id_fk": { - "name": "property_removal_requests_portfolio_id_portfolio_id_fk", - "tableFrom": "property_removal_requests", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "property_removal_requests_requested_by_user_id_fk": { - "name": "property_removal_requests_requested_by_user_id_fk", - "tableFrom": "property_removal_requests", - "tableTo": "user", - "columnsFrom": [ - "requested_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "property_removal_requests_reviewed_by_user_id_fk": { - "name": "property_removal_requests_reviewed_by_user_id_fk", - "tableFrom": "property_removal_requests", - "tableTo": "user", - "columnsFrom": [ - "reviewed_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.solar": { - "name": "solar", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "longitude": { - "name": "longitude", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "latitude": { - "name": "latitude", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "google_api_response": { - "name": "google_api_response", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.solar_scenario": { - "name": "solar_scenario", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "solar_id": { - "name": "solar_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "scenario_type": { - "name": "scenario_type", - "type": "scenario_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "number_panels": { - "name": "number_panels", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "array_kwhp": { - "name": "array_kwhp", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "lifetime_dc_kwh": { - "name": "lifetime_dc_kwh", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "yearly_dc_kwh": { - "name": "yearly_dc_kwh", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "lifetime_ac_kwh": { - "name": "lifetime_ac_kwh", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "yearly_ac_kwh": { - "name": "yearly_ac_kwh", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "cost": { - "name": "cost", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "expected_payback_years": { - "name": "expected_payback_years", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "panelled_roof_area": { - "name": "panelled_roof_area", - "type": "real", - "primaryKey": false, - "notNull": true - }, - "is_default": { - "name": "is_default", - "type": "boolean", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "solar_scenario_solar_id_solar_id_fk": { - "name": "solar_scenario_solar_id_solar_id_fk", - "tableFrom": "solar_scenario", - "tableTo": "solar", - "columnsFrom": [ - "solar_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sub_task": { - "name": "sub_task", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "task_id": { - "name": "task_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "job_started": { - "name": "job_started", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "job_completed": { - "name": "job_completed", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'In Progress'" - }, - "inputs": { - "name": "inputs", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "outputs": { - "name": "outputs", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cloud_logs_url": { - "name": "cloud_logs_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "sub_task_task_id_tasks_id_fk": { - "name": "sub_task_task_id_tasks_id_fk", - "tableFrom": "sub_task", - "tableTo": "tasks", - "columnsFrom": [ - "task_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tasks": { - "name": "tasks", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "task_source": { - "name": "task_source", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "job_started": { - "name": "job_started", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "job_completed": { - "name": "job_completed", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'In Progress'" - }, - "service": { - "name": "service", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source": { - "name": "source", - "type": "source", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.team": { - "name": "team", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "org_id": { - "name": "org_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "team_org_id_organisation_id_fk": { - "name": "team_org_id_organisation_id_fk", - "tableFrom": "team", - "tableTo": "organisation", - "columnsFrom": [ - "org_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.team_members": { - "name": "team_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "team_members_user_id_user_id_fk": { - "name": "team_members_user_id_user_id_fk", - "tableFrom": "team_members", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "team_members_team_id_team_id_fk": { - "name": "team_members_team_id_team_id_fk", - "tableFrom": "team_members", - "tableTo": "team", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.team_portfolio_permissions": { - "name": "team_portfolio_permissions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "portfolio_id": { - "name": "portfolio_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "team_portfolio_permissions_team_id_team_id_fk": { - "name": "team_portfolio_permissions_team_id_team_id_fk", - "tableFrom": "team_portfolio_permissions", - "tableTo": "team", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "team_portfolio_permissions_portfolio_id_portfolio_id_fk": { - "name": "team_portfolio_permissions_portfolio_id_portfolio_id_fk", - "tableFrom": "team_portfolio_permissions", - "tableTo": "portfolio", - "columnsFrom": [ - "portfolio_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.uploaded_files": { - "name": "uploaded_files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "s3_file_bucket": { - "name": "s3_file_bucket", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "s3_file_key": { - "name": "s3_file_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "s3_upload_timestamp": { - "name": "s3_upload_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "landlord_property_id": { - "name": "landlord_property_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "uprn": { - "name": "uprn", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "hubspot_deal_id": { - "name": "hubspot_deal_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "hubspot_listing_id": { - "name": "hubspot_listing_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "file_type": { - "name": "file_type", - "type": "file_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "file_source": { - "name": "file_source", - "type": "file_source", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "measure_name": { - "name": "measure_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "uploaded_by": { - "name": "uploaded_by", - "type": "bigint", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "uploaded_files_uploaded_by_user_id_fk": { - "name": "uploaded_files_uploaded_by_user_id_fk", - "tableFrom": "uploaded_files", - "tableTo": "user", - "columnsFrom": [ - "uploaded_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.account": { - "name": "account", - "schema": "", - "columns": { - "userId": { - "name": "userId", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "providerAccountId": { - "name": "providerAccountId", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "token_type": { - "name": "token_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "session_state": { - "name": "session_state", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "account_userId_user_id_fk": { - "name": "account_userId_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "account_provider_providerAccountId_pk": { - "name": "account_provider_providerAccountId_pk", - "columns": [ - "provider", - "providerAccountId" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "sessionToken": { - "name": "sessionToken", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "userId": { - "name": "userId", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "expires": { - "name": "expires", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "session_userId_user_id_fk": { - "name": "session_userId_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "firstName": { - "name": "firstName", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "emailVerified": { - "name": "emailVerified", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "oauth_id": { - "name": "oauth_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "oauth_provider": { - "name": "oauth_provider", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "onboarded": { - "name": "onboarded", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "last_login": { - "name": "last_login", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_profiles": { - "name": "user_profiles", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "user_type": { - "name": "user_type", - "type": "user_profiles_user_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "property_count": { - "name": "property_count", - "type": "user_profiles_property_count", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "goals": { - "name": "goals", - "type": "json", - "primaryKey": false, - "notNull": false - }, - "referral_source": { - "name": "referral_source", - "type": "user_profiles_referral_source", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "nrla_membership_id": { - "name": "nrla_membership_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "accepted_privacy": { - "name": "accepted_privacy", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "accepted_privacy_at": { - "name": "accepted_privacy_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "marketing_opt_in": { - "name": "marketing_opt_in", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "marketing_opt_in_at": { - "name": "marketing_opt_in_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": false - }, - "first_name": { - "name": "first_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp (6) with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "user_profiles_user_id_user_id_fk": { - "name": "user_profiles_user_id_user_id_fk", - "tableFrom": "user_profiles", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verificationToken": { - "name": "verificationToken", - "schema": "", - "columns": { - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires": { - "name": "expires", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "verificationToken_identifier_token_pk": { - "name": "verificationToken_identifier_token_pk", - "columns": [ - "identifier", - "token" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.whlg": { - "name": "whlg", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "postcode": { - "name": "postcode", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.aspect_type": { - "name": "aspect_type", - "schema": "public", - "values": [ - "material", - "condition", - "type", - "area", - "configuration", - "presence", - "risk", - "severity", - "location", - "finish", - "insulation", - "pointing", - "spalling", - "lintels", - "cladding", - "category", - "quantity", - "adequacy", - "rating", - "strategy", - "extent", - "distribution", - "structure", - "covering", - "fire_rating", - "external_decoration", - "work_required", - "age_band", - "construction_type", - "classification", - "system" - ] - }, - "public.element_type": { - "name": "element_type", - "schema": "public", - "values": [ - "property", - "property_construction_type", - "property_classification", - "property_age_band", - "storey_count", - "floor_level", - "floor_level_front_door", - "accessible_housing_register", - "asbestos", - "quality_standard", - "ccu", - "passenger_lift", - "stairlift", - "disabled_hoist_tracking", - "disabled_facilities", - "steps_to_front_door", - "roof", - "pitched_roof_covering", - "flat_roof_covering", - "rainwater_goods", - "loft_insulation", - "porch_canopy", - "chimney", - "fascia", - "soffit", - "fascia_soffit_bargeboards", - "gutters", - "store_roof", - "garage_roof", - "garage_and_store_roof", - "external_wall", - "external_noise_insulation", - "primary_wall", - "secondary_wall", - "downpipes", - "external_decoration", - "cladding", - "spandrel_panels", - "garage_walls", - "party_wall_fire_break", - "external_brickwork_pointing", - "internal_downpipes_external_area", - "external_windows", - "communal_windows", - "secondary_glazing", - "store_windows", - "garage_windows", - "garage_and_store_windows", - "external_door", - "front_door", - "rear_door", - "store_door", - "garage_door", - "garage_and_store_door", - "communal_entrance_door", - "main_door", - "block_entrance_door", - "lintel", - "patio_french_door", - "door_entry_handset", - "paths_and_hardstandings", - "parking_areas", - "boundary_walls", - "front_fencing", - "rear_fencing", - "side_fencing", - "rear_gate", - "front_gate", - "gates", - "retaining_walls", - "private_balcony", - "balcony_balustrade", - "outbuildings", - "garage_structure", - "paving", - "roads", - "soil_and_vent", - "solar_thermals", - "drop_kerb", - "outbuilding_overhaul", - "external_structural_defects", - "access_ramp", - "kitchen", - "kitchen_space_layout", - "tenant_installed_kitchen", - "kitchen_extractor_fan", - "bathroom", - "secondary_bathroom", - "secondary_toilet", - "bathroom_extractor_fan", - "additional_wc_or_whb", - "bathroom_remaining_life_source", - "kitchen_remaining_life_source", - "central_heating", - "heating_boiler", - "heating_distribution", - "secondary_heating", - "hot_water_system", - "cold_water_storage", - "heating_system", - "boiler_fuel", - "water_heating", - "programmable_heating", - "community_heating", - "gas_available", - "heat_recovery_units", - "heating_improvements", - "electrical_wiring", - "consumer_unit", - "smoke_detection", - "heat_detection", - "carbon_monoxide_detection", - "fire_door_rating", - "fire_risk_assessment", - "internal_wiring", - "electrics", - "communal_heating", - "communal_boiler", - "communal_electrics", - "communal_fire_alarm", - "communal_emergency_lighting", - "communal_door_entry", - "communal_cctv", - "communal_bin_store", - "communal_bin_store_doors", - "communal_bin_store_walls", - "communal_bin_store_roof", - "communal_refuse_chute", - "communal_floor_covering", - "communal_kitchen", - "communal_bathroom", - "communal_toilets", - "communal_gates", - "communal_lift", - "communal_passenger_lift", - "communal_balcony_walkway", - "communal_entrance", - "communal_internal_decorations", - "communal_internal_floor", - "communal_walkways", - "communal_external_doors", - "communal_stairs", - "communal_aerial", - "communal_aov", - "communal_internal_doors", - "communal_lateral_mains", - "communal_lighting", - "communal_lighting_conductor", - "communal_store_roof", - "communal_store_walls", - "communal_store_doors", - "communal_warden_call_system", - "communal_bms", - "communal_booster_pump", - "communal_dry_riser", - "communal_wet_riser", - "communal_cold_water_storage", - "communal_sprinkler", - "communal_plug_sockets", - "communal_circulation_space", - "ffhh_damp", - "ffhh_hold_and_cold_water", - "ffhh_drainage_lavatories", - "ffhh_neglected", - "ffhh_natural_light", - "ffhh_ventilation", - "ffhh_food_prep_and_washup", - "ffhh_unsafe_layout", - "ffhh_unstable_building", - "hhsrs_damp_and_mould", - "hhsrs_excess_cold", - "hhsrs_excess_heat", - "hhsrs_asbestos_and_mmf", - "hhsrs_biocides", - "hhsrs_carbon_monoxide", - "hhsrs_lead", - "hhsrs_radiation", - "hhsrs_uncombusted_fuel_gas", - "hhsrs_volatile_organic_compounds", - "hhsrs_crowding_and_space", - "hhsrs_entry_by_intruders", - "hhsrs_lighting", - "hhsrs_noise", - "hhsrs_domestic_hygiene_pests_refuse", - "hhsrs_food_safety", - "hhsrs_personal_hygiene_sanitation", - "hhsrs_water_supply", - "hhsrs_falls_associated_with_baths", - "hhsrs_falls_on_level_surfaces", - "hhsrs_falls_on_stairs", - "hhsrs_falls_between_levels", - "hhsrs_electrical_hazards", - "hhsrs_fire", - "hhsrs_flames_hot_surfaces", - "hhsrs_collision_and_entrapment", - "hhsrs_collision_hazards_low_headroom", - "hhsrs_explosions", - "hhsrs_ergonomics", - "hhsrs_structural_collapse", - "hhsrs_amenities" - ] - }, - "public.document_type": { - "name": "document_type", - "schema": "public", - "values": [ - "EPR", - "Condition Report", - "Evidence Report", - "Summary Information", - "Floor Plan", - "Scenario Draft EPC", - "Scenario Site Notes" - ] - }, - "public.scheme": { - "name": "scheme", - "schema": "public", - "values": [ - "eco4", - "gbis", - "whlg", - "none" - ] - }, - "public.inspection_archetype_2": { - "name": "inspection_archetype_2", - "schema": "public", - "values": [ - "detached", - "mid-terrace", - "enclosed mid-terrace", - "end-terrace", - "enclosed end-terrace", - "semi-detached" - ] - }, - "public.inspection_archetype": { - "name": "inspection_archetype", - "schema": "public", - "values": [ - "Bungalow", - "Flat", - "Maisonette", - "House", - "non-domestic" - ] - }, - "public.inspection_borescoped": { - "name": "inspection_borescoped", - "schema": "public", - "values": [ - "yes", - "no", - "refused" - ] - }, - "public.inspections_access_issues": { - "name": "inspections_access_issues", - "schema": "public", - "values": [ - "see notes", - "damp issues", - "foliage on walls", - "bushes against wall", - "trees around/anove property", - "high rise block flats/maisonettes", - "conservatory", - "lean-to", - "garage", - "extension", - "decking", - "shed against wall" - ] - }, - "public.inspections_cladding": { - "name": "inspections_cladding", - "schema": "public", - "values": [ - "none", - "cladded with “sufficient space to fill the wall”", - "cladded with “insufficient space to fill the wall”" - ] - }, - "public.inspections_insulation_material": { - "name": "inspections_insulation_material", - "schema": "public", - "values": [ - "empty 50-90", - "empty 100+", - "empty 30-40", - "empty less than 30", - "loose fibre/wool", - "eps/celo/king", - "fibre batts - with cavity", - "fibre batts - no cavity", - "loose bead", - "glued bead", - "formaldehyde", - "bubble wrap", - "poly chunks" - ] - }, - "public.inspections_rendered": { - "name": "inspections_rendered", - "schema": "public", - "values": [ - "no render", - "rendered with “insufficient” space between dpc and render", - "rendered with “sufficient” space between dpc and render" - ] - }, - "public.inspections_roof_orientation": { - "name": "inspections_roof_orientation", - "schema": "public", - "values": [ - "north", - "east", - "south", - "west", - "north-east", - "north-west", - "south-east", - "south-west", - "n/s split", - "e/w split", - "ne/sw split", - "nw/se split", - "flat roof", - "no roof", - "roof too small", - "already has solar pv" - ] - }, - "public.inspections_tile_hung": { - "name": "inspections_tile_hung", - "schema": "public", - "values": [ - "yes", - "no", - "first floor flats are tile hung" - ] - }, - "public.inspections_wall_construction": { - "name": "inspections_wall_construction", - "schema": "public", - "values": [ - "cavity", - "solid", - "system built", - "timber framed", - "steel framed", - "re-walled cavity", - "mansard pre-fab", - "mansard ewi", - "mansard re-walled" - ] - }, - "public.inspections_wall_insulation": { - "name": "inspections_wall_insulation", - "schema": "public", - "values": [ - "empty cavity", - "filled at build", - "partial", - "retro drilled", - "ewi", - "iwi", - "solid non-cavity", - "system built", - "timber framed", - "steel framed" - ] - }, - "public.cost_unit": { - "name": "cost_unit", - "schema": "public", - "values": [ - "gbp_sq_meter", - "gbp_per_unit", - "gbp_per_m2", - "gbp_per_m" - ] - }, - "public.depth_unit": { - "name": "depth_unit", - "schema": "public", - "values": [ - "mm" - ] - }, - "public.type": { - "name": "type", - "schema": "public", - "values": [ - "suspended_floor_insulation", - "solid_floor_insulation", - "external_wall_insulation", - "internal_wall_insulation", - "cavity_wall_insulation", - "mechanical_ventilation", - "loft_insulation", - "exposed_floor_insulation", - "flat_roof_insulation", - "room_roof_insulation", - "cavity_wall_extraction", - "iwi_wall_demolition", - "iwi_vapour_barrier", - "iwi_redecoration", - "suspended_floor_demolition", - "suspended_floor_redecoration", - "suspended_floor_vapour_barrier", - "solid_floor_demolition", - "solid_floor_preparation", - "solid_floor_vapour_barrier", - "solid_floor_redecoration", - "ewi_wall_demolition", - "ewi_wall_preparation", - "ewi_wall_redecoration", - "low_energy_lighting_installation", - "flat_roof_preparation", - "flat_roof_vapour_barrier", - "flat_roof_waterproofing", - "windows_glazing", - "secondary_glazing", - "double_glazing", - "trickle_vent", - "door_undercut", - "solar_pv", - "solar_battery", - "scaffolding", - "high_heat_retention_storage_heaters", - "air_source_heat_pump", - "boiler_upgrade", - "roomstat_programmer_trvs", - "time_temperature_zone_control", - "sealing_fireplace" - ] - }, - "public.r_value_unit": { - "name": "r_value_unit", - "schema": "public", - "values": [ - "square_meter_kelvin_per_watt" - ] - }, - "public.size_unit": { - "name": "size_unit", - "schema": "public", - "values": [ - "kWp", - "kW", - "watt", - "storey" - ] - }, - "public.thermal_conductivity_unit": { - "name": "thermal_conductivity_unit", - "schema": "public", - "values": [ - "watt_per_meter_kelvin" - ] - }, - "public.goal": { - "name": "goal", - "schema": "public", - "values": [ - "Valuation Improvement", - "Increasing EPC", - "Reducing CO2 emissions", - "Energy Savings", - "None" - ] - }, - "public.portfolio_capability": { - "name": "portfolio_capability", - "schema": "public", - "values": [ - "approver", - "contractor" - ] - }, - "public.role": { - "name": "role", - "schema": "public", - "values": [ - "creator", - "admin", - "read", - "write" - ] - }, - "public.status": { - "name": "status", - "schema": "public", - "values": [ - "scoping", - "survey", - "assessment", - "tendering", - "project underway", - "completion; status: on track", - "completion; status: delayed", - "completion; status: at risk", - "completion; status: completed", - "needs review" - ] - }, - "public.epc": { - "name": "epc", - "schema": "public", - "values": [ - "A", - "B", - "C", - "D", - "E", - "F", - "G" - ] - }, - "public.creation_status": { - "name": "creation_status", - "schema": "public", - "values": [ - "LOADING", - "READY", - "ERROR" - ] - }, - "public.housing_type": { - "name": "housing_type", - "schema": "public", - "values": [ - "Private", - "Social" - ] - }, - "public.measure_type": { - "name": "measure_type", - "schema": "public", - "values": [ - "air_source_heat_pump", - "boiler_upgrade", - "high_heat_retention_storage_heaters", - "secondary_heating", - "roomstat_programmer_trvs", - "time_temperature_zone_control", - "cylinder_thermostat", - "cavity_wall_insulation", - "extension_cavity_wall_insulation", - "external_wall_insulation", - "internal_wall_insulation", - "loft_insulation", - "flat_roof_insulation", - "room_roof_insulation", - "solid_floor_insulation", - "suspended_floor_insulation", - "double_glazing", - "secondary_glazing", - "draught_proofing", - "mechanical_ventilation", - "low_energy_lighting", - "solar_pv", - "hot_water_tank_insulation", - "sealing_open_fireplace" - ] - }, - "public.plan_type": { - "name": "plan_type", - "schema": "public", - "values": [ - "solar_eco4", - "solar_hhrsh_eco4", - "empty_cavity_eco", - "partial_cavity_eco", - "extraction_eco" - ] - }, - "public.unit_quantity": { - "name": "unit_quantity", - "schema": "public", - "values": [ - "m2", - "part", - "kwp" - ] - }, - "public.scenario_type": { - "name": "scenario_type", - "schema": "public", - "values": [ - "unit", - "building" - ] - }, - "public.source": { - "name": "source", - "schema": "public", - "values": [ - "portfolio_id" - ] - }, - "public.file_source": { - "name": "file_source", - "schema": "public", - "values": [ - "pas hub", - "sharepoint", - "hubspot", - "ecmk", - "contractor" - ] - }, - "public.file_type": { - "name": "file_type", - "schema": "public", - "values": [ - "photo_pack", - "site_note", - "rd_sap_site_note", - "pas_2023_ventilation", - "pas_2023_condition", - "pas_significance", - "par_photo_pack", - "pas_2023_property", - "pas_2023_occupancy", - "ecmk_site_note", - "ecmk_rd_sap_site_note", - "ecmk_survey_xml", - "pre_photo", - "mid_photo", - "post_photo", - "loft_hatch_photo", - "dmev_photos", - "door_undercut_photos", - "trickle_vent_photos", - "pre_installation_building_inspection", - "point_of_work_risk_assessment", - "claim_of_compliance", - "mcs_compliance_certificate", - "certificate_of_conformity", - "minor_works_electrical_certificate", - "trustmark_licence_numbers", - "operative_competency", - "ventilation_assessment_checklist", - "anemometer_readings", - "commissioning_records", - "part_f_ventilation_document", - "handover_pack", - "insurance_guarantee", - "workmanship_warranty", - "g98_notification", - "installer_qualifications", - "installer_feedback", - "contractor_other" - ] - }, - "public.user_profiles_property_count": { - "name": "user_profiles_property_count", - "schema": "public", - "values": [ - "1", - "2–5", - "6–20", - "21+", - "1–50", - "51–100", - "101–300", - "301–1000", - "1000+" - ] - }, - "public.user_profiles_referral_source": { - "name": "user_profiles_referral_source", - "schema": "public", - "values": [ - "search", - "social_media", - "NRLA", - "partner", - "word_of_mouth", - "other" - ] - }, - "public.user_profiles_user_type": { - "name": "user_profiles_user_type", - "schema": "public", - "values": [ - "private_landlord", - "private_tenant", - "social_landlord", - "social_tenant", - "homeowner", - "other" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/src/app/db/migrations/meta/_journal.json b/src/app/db/migrations/meta/_journal.json index 3b58c43..ef1efd0 100644 --- a/src/app/db/migrations/meta/_journal.json +++ b/src/app/db/migrations/meta/_journal.json @@ -1254,13 +1254,6 @@ "when": 1776458454019, "tag": "0178_parched_midnight", "breakpoints": true - }, - { - "idx": 179, - "version": "7", - "when": 1776459924335, - "tag": "0179_mighty_cardiac", - "breakpoints": true } ] } \ No newline at end of file From e6a71e4e0cbd92bcfd1a087367679b18e7981c8c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sat, 18 Apr 2026 19:10:33 +0000 Subject: [PATCH 03/22] claude settings as ewll --- .claude/settings.local.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a71d887 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(backlog task *)", + "Bash(backlog mcp *)" + ] + } +} From 776c88409c5cb4ef55ced2e614ddb4c297309298 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 20 Apr 2026 14:58:52 +0000 Subject: [PATCH 04/22] added backlog --- .claude/settings.local.json | 10 +- .devcontainer/Dockerfile | 13 ++- .devcontainer/devcontainer.json | 3 +- .devcontainer/docker-compose.yml | 4 + CLAUDE.md | 16 +++ ...INER_QUEUE_NAME-to-staging-and-prod-env.md | 26 ----- ...s-when-combined_output_s3_uri-populated.md | 31 ++++++ ...biner-queue-to-assessment-model-runtime.md | 25 ----- ...bda-queue-via-terraform-to-staging-prod.md | 26 ----- ...- Smoke-test-combiner-end-to-end-on-dev.md | 21 +++- ...-call-backend-trigger-splitter-endpoint.md | 105 ++++++++++++++++++ ...-route-to-call-backend-trigger-combiner.md | 32 ++++++ ...irm-matches-page-for-bulk-upload-review.md | 34 ++++++ ...or-combined-results-and-confirm-matches.md | 35 ++++++ run_backlog_browser.sh | 1 + .../[uploadId]/combined-results/route.ts | 67 +++++++++++ .../[uploadId]/confirm-matches/route.ts | 70 ++++++++++++ .../bulk-uploads/[uploadId]/onboard/route.ts | 40 +++++-- 18 files changed, 462 insertions(+), 97 deletions(-) delete mode 100644 backlog/tasks/task-1 - Add-BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME-to-staging-and-prod-env.md create mode 100644 backlog/tasks/task-10 - Redirect-to-confirm-matches-when-combined_output_s3_uri-populated.md delete mode 100644 backlog/tasks/task-2 - Grant-sqs-SendMessage-IAM-on-combiner-queue-to-assessment-model-runtime.md delete mode 100644 backlog/tasks/task-3 - Deploy-bulk_address2uprn_combiner-Lambda-queue-via-terraform-to-staging-prod.md create mode 100644 backlog/tasks/task-6 - Refactor-onboard-route-to-call-backend-trigger-splitter-endpoint.md create mode 100644 backlog/tasks/task-7 - Refactor-combine-route-to-call-backend-trigger-combiner.md create mode 100644 backlog/tasks/task-8 - Add-confirm-matches-page-for-bulk-upload-review.md create mode 100644 backlog/tasks/task-9 - Add-proxy-api-routes-for-combined-results-and-confirm-matches.md create mode 100644 run_backlog_browser.sh create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/confirm-matches/route.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a71d887..b6b929a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,13 @@ "permissions": { "allow": [ "Bash(backlog task *)", - "Bash(backlog mcp *)" + "Bash(backlog mcp *)", + "Read(//home/vscode/.config/nvim/**)", + "Read(//home/vscode/.config/nvim/lua/plugins/**)", + "Bash(npx tsc *)" ] - } + }, + "enabledMcpjsonServers": [ + "backlog" + ] } diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d3c123c..6a81a00 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM library/python:3.12-bullseye +FROM library/python:3.12-bookworm ARG USER=vscode ARG USER_UID=1000 @@ -43,8 +43,19 @@ RUN npm install -g backlog.md # RUN apt-get install terraform # RUN terraform -install-autocomplete +# Install Neovim (latest) + LazyVim deps +RUN curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \ + | tar -xz -C /opt \ + && ln -s /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim \ + && apt update && apt install -y --no-install-recommends \ + ripgrep fd-find git make unzip \ + && rm -rf /var/lib/apt/lists/* + # Install Claude USER ${USER} +# Bootstrap LazyVim starter config +RUN git clone https://github.com/LazyVim/starter /home/${USER}/.config/nvim \ + && rm -rf /home/${USER}/.config/nvim/.git RUN curl -fsSL https://claude.ai/install.sh | bash \ && export PATH="/home/${USER}/.local/bin:${PATH}" \ && claude plugin marketplace add JuliusBrussee/caveman \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 72efbbc..69f8eeb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,7 +21,8 @@ }, "extensions": [ "esbenp.prettier-vscode", - "Anthropic.claude-code" + "Anthropic.claude-code", + "asvetliakov.vscode-neovim" ] } } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 1c8e315..ed3c80c 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -9,8 +9,12 @@ services: command: sleep infinity ports: - "3000:3000" + - "6420:6420" volumes: - ..:/workspaces/assessment-model + - ~/.gitconfig:/home/vscode/.gitconfig:ro + environment: + - SSH_AUTH_SOCK=${SSH_AUTH_SOCK:-} networks: - frontend-net diff --git a/CLAUDE.md b/CLAUDE.md index 8a6b88d..b8c1d54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,3 +15,19 @@ - Tasks live as markdown under `backlog/tasks/`. Committed to git. Read them for context on outstanding manual work (env vars, IAM, infra) owed by humans. - To start the web UI during development: `backlog browser` (port 6420, forwarded by devcontainer). - Do NOT mirror Backlog.md tasks into Claude's internal todo system. Use one or the other — Backlog for durable cross-session work, internal todos for within-turn progress tracking. + +## Development workflow (spec-driven) + +Follow this loop for all feature work: + +1. **Decompose** — split user request into small Backlog tasks with acceptance criteria. One task = one PR = one session. +2. **Plan first** — before writing code, research codebase and write implementation plan inside the task. Stop and wait for user approval. +3. **Implement** — only after plan approved. One task at a time. +4. **Verify** — run tests/lint, confirm output matches acceptance criteria. + +**Hard rules:** +- Never start coding without an approved plan in the task. +- Never work on multiple tasks in one session. +- If task too big to finish in one session, split it first. + + diff --git a/backlog/tasks/task-1 - Add-BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME-to-staging-and-prod-env.md b/backlog/tasks/task-1 - Add-BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME-to-staging-and-prod-env.md deleted file mode 100644 index b4938e5..0000000 --- a/backlog/tasks/task-1 - Add-BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME-to-staging-and-prod-env.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -id: TASK-1 -title: Add BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME to staging and prod env -status: To Do -assignee: - - Jun-te Kim -created_date: '2026-04-18 19:01' -labels: - - env - - infra -dependencies: [] -priority: high ---- - -## Description - - -Dev .env.local has it; non-dev envs still missing. Combine route returns 500 'Server misconfiguration' without it. - - -## Acceptance Criteria - -- [ ] #1 Value set in staging env: bulk-address2uprn-combiner-queue-staging (or matching stage suffix) -- [ ] #2 Value set in prod env: bulk-address2uprn-combiner-queue-prod -- [ ] #3 Deploy redeployed; /api/portfolio/{pid}/bulk-uploads/{uid}/combine returns 200 not 500 - diff --git a/backlog/tasks/task-10 - Redirect-to-confirm-matches-when-combined_output_s3_uri-populated.md b/backlog/tasks/task-10 - Redirect-to-confirm-matches-when-combined_output_s3_uri-populated.md new file mode 100644 index 0000000..3dbff76 --- /dev/null +++ b/backlog/tasks/task-10 - Redirect-to-confirm-matches-when-combined_output_s3_uri-populated.md @@ -0,0 +1,31 @@ +--- +id: TASK-10 +title: Redirect to confirm-matches when combined_output_s3_uri populated +status: To Do +assignee: [] +created_date: '2026-04-20' +updated_date: '2026-04-20' +labels: + - frontend + - bulk-upload + - ui +dependencies: + - TASK-8 +priority: medium +ordinal: 5000 +--- + +## Description + + +`OnboardingProgress.tsx` currently fires client-side combine POST when task terminal. Once backend auto-chains (backend-task-5) OR frontend triggers via backend route (task-7), the polling should watch for `bulk_address_uploads.combined_output_s3_uri` to be set. When present, show "Review matches →" CTA (or auto-redirect to confirm-matches page, task-8). + +May require a new GET endpoint that returns `{status, combined_output_s3_uri}` for polling. Or extend existing task summary with upload fields. + + +## Acceptance Criteria + +- [ ] #1 Polling stops once combined_output_s3_uri populated +- [ ] #2 UI surfaces a clear CTA to review matches +- [ ] #3 No duplicate combiner fires across refreshes + diff --git a/backlog/tasks/task-2 - Grant-sqs-SendMessage-IAM-on-combiner-queue-to-assessment-model-runtime.md b/backlog/tasks/task-2 - Grant-sqs-SendMessage-IAM-on-combiner-queue-to-assessment-model-runtime.md deleted file mode 100644 index 5ca196a..0000000 --- a/backlog/tasks/task-2 - Grant-sqs-SendMessage-IAM-on-combiner-queue-to-assessment-model-runtime.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -id: TASK-2 -title: 'Grant sqs:SendMessage IAM on combiner queue to assessment-model runtime' -status: To Do -assignee: - - Jun-te Kim -created_date: '2026-04-18 19:01' -labels: - - infra - - iam -dependencies: [] -priority: high ---- - -## Description - - -Combine route sends to bulk-address2uprn-combiner-queue-. Runtime role needs sqs:SendMessage + sqs:GetQueueUrl on that queue ARN. - - -## Acceptance Criteria - -- [ ] #1 IAM policy updated in terraform for staging + prod -- [ ] #2 Verified via AWS console or 'aws sqs get-queue-url' using runtime creds - diff --git a/backlog/tasks/task-3 - Deploy-bulk_address2uprn_combiner-Lambda-queue-via-terraform-to-staging-prod.md b/backlog/tasks/task-3 - Deploy-bulk_address2uprn_combiner-Lambda-queue-via-terraform-to-staging-prod.md deleted file mode 100644 index fecfa88..0000000 --- a/backlog/tasks/task-3 - Deploy-bulk_address2uprn_combiner-Lambda-queue-via-terraform-to-staging-prod.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -id: TASK-3 -title: Deploy bulk_address2uprn_combiner Lambda + queue via terraform to staging/prod -status: To Do -assignee: - - Jun-te Kim -created_date: '2026-04-18 19:01' -labels: - - infra - - terraform -dependencies: [] -priority: high ---- - -## Description - - -Lambda source at /workspaces/home/github/Model/backend/bulk_address2uprn_combiner/. Uses lambda_with_sqs module. Needs S3_BUCKET_NAME=retrofit_sap_data_bucket_name and DB creds envs. Confirm queue name convention bulk-address2uprn-combiner-queue-. - - -## Acceptance Criteria - -- [ ] #1 Lambda + queue exist in staging -- [ ] #2 Lambda + queue exist in prod -- [ ] #3 Lambda has read on ara_raw_outputs/ and write on bulk_final_outputs/ in retrofit_sap_data bucket - diff --git a/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md b/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md index 59a8215..95911ab 100644 --- a/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md +++ b/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md @@ -1,27 +1,36 @@ --- id: TASK-4 -title: Smoke-test combiner end-to-end on dev +title: Smoke-test full bulk upload flow end-to-end on dev status: To Do assignee: - Jun-te Kim created_date: '2026-04-18 19:02' +updated_date: '2026-04-20' labels: - qa - bulk-upload -dependencies: [] +dependencies: + - TASK-6 + - TASK-7 + - TASK-8 + - TASK-9 + - TASK-10 priority: medium +ordinal: 9000 --- ## Description -After env var + IAM ready, run a real bulk upload -> map columns -> onboard -> wait for terminal complete. Confirm combiner fires. +After frontend + backend refactor ships, run the full flow on dev: upload xlsx → map columns → start onboarding → poll task progress → combiner fires → combined_output_s3_uri populated → review matches page renders → confirm rows → addresses appear in portfolio. ## Acceptance Criteria -- [ ] #1 POST /combine returns 200 with {taskId, subTaskId} -- [ ] #2 CloudWatch for bulk_address2uprn_combiner shows the subtask picked up +- [ ] #1 Frontend no longer needs POSTCODE_SPLITTER_QUEUE_NAME or BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME env vars +- [ ] #2 Backend logs show both splitter + combiner triggered via HTTP route - [ ] #3 bulk_final_outputs/{task_id}/combined_.csv exists in retrofit_sap_data bucket -- [ ] #4 bulk_address_uploads.combined_output_s3_uri populated for the test upload +- [ ] #4 bulk_address_uploads.combined_output_s3_uri populated +- [ ] #5 Confirm-matches page renders with match rows +- [ ] #6 After confirm submit, addresses persist into portfolio diff --git a/backlog/tasks/task-6 - Refactor-onboard-route-to-call-backend-trigger-splitter-endpoint.md b/backlog/tasks/task-6 - Refactor-onboard-route-to-call-backend-trigger-splitter-endpoint.md new file mode 100644 index 0000000..310c2fa --- /dev/null +++ b/backlog/tasks/task-6 - Refactor-onboard-route-to-call-backend-trigger-splitter-endpoint.md @@ -0,0 +1,105 @@ +--- +id: TASK-6 +title: Refactor onboard route to call backend trigger-splitter endpoint +status: In Progress +assignee: [] +created_date: '2026-04-20' +updated_date: '2026-04-20 12:55' +labels: + - frontend + - bulk-upload + - refactor +dependencies: + - BACKEND-TASK-1 +priority: high +ordinal: 1000 +--- + +## Description + + +Currently `src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts` builds an SQS message and sends it to `POSTCODE_SPLITTER_QUEUE_NAME`. Move the SQS send to the backend. Frontend still transforms XLSX → CSV + uploads to S3, then calls backend HTTP `POST /v1/bulk-uploads/trigger-splitter`. Drop `POSTCODE_SPLITTER_QUEUE_NAME`, `sendToQueue`, and SQS IAM dependency. + + +## Acceptance Criteria + +- [x] #1 `onboard/route.ts` no longer imports `sendToQueue` or reads `POSTCODE_SPLITTER_QUEUE_NAME` +- [x] #2 Transformed CSV still uploaded to `bulk_onboarding_inputs/{portfolioId}/{uploadId}.csv` +- [x] #3 Backend trigger-splitter endpoint called with correct payload +- [ ] #4 DB updates (bulk_address_uploads.status="processing", tasks.status, subTasks.inputs) still happen on success +- [ ] #5 4xx/5xx from backend → return 502 to client with useful message + + +## Implementation Plan + + + + +### File changed +`src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts` + +### What stays the same +- Auth check via `getServerSession` +- Upload record + status validation +- `transformFile()` XLSX → CSV +- S3 upload of transformed CSV to `bulk_onboarding_inputs/{portfolioId}/{uploadId}.csv` +- DB writes: `bulkAddressUploads.status = "processing"`, `tasks.status = "in progress"`, `subTasks.inputs` + +### What changes + +**Remove:** +```ts +import { sendToQueue } from "@/app/utils/sqs"; +// POSTCODE_SPLITTER_QUEUE_NAME env var check +``` + +**Add — call backend trigger-splitter:** +```ts +const fastapiUrl = process.env.FASTAPI_API_URL; +const fastapiKey = process.env.FASTAPI_API_KEY; +if (!fastapiUrl || !fastapiKey) { + return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); +} + +const sessionToken = + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value; + +const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-splitter`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": fastapiKey, + Authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify({ + task_id: body.taskId, + sub_task_id: body.subTaskId, + s3_uri: s3Uri, + }), +}); + +if (!triggerRes.ok) { + const errText = await triggerRes.text().catch(() => ""); + console.error("Backend trigger-splitter failed:", triggerRes.status, errText); + return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 }); +} +``` + +**Order of ops** (no change to structure, just swap): +1. Validate body + upload record +2. Read source file from S3 +3. Transform XLSX → CSV +4. Upload transformed CSV to S3 +5. ~~sendToQueue~~ → fetch backend trigger-splitter +6. DB updates (status, taskId, subTask inputs) — only after step 5 succeeds + +### Env vars required +- `FASTAPI_API_URL` — already set, already used in `plan/trigger/route.ts` +- `FASTAPI_API_KEY` — already set + +### Env vars removed +- `POSTCODE_SPLITTER_QUEUE_NAME` — no longer read by frontend (can remove from .env.local + staging/prod) + + + diff --git a/backlog/tasks/task-7 - Refactor-combine-route-to-call-backend-trigger-combiner.md b/backlog/tasks/task-7 - Refactor-combine-route-to-call-backend-trigger-combiner.md new file mode 100644 index 0000000..19ad491 --- /dev/null +++ b/backlog/tasks/task-7 - Refactor-combine-route-to-call-backend-trigger-combiner.md @@ -0,0 +1,32 @@ +--- +id: TASK-7 +title: Refactor combine route to call backend trigger-combiner endpoint +status: To Do +assignee: [] +created_date: '2026-04-20' +updated_date: '2026-04-20' +labels: + - frontend + - bulk-upload + - refactor +dependencies: + - BACKEND-TASK-2 +priority: high +ordinal: 2000 +--- + +## Description + + +`src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts` sends SQS directly to `BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME`. Replace with call to backend `POST /bulk-uploads/{task_id}/combine`. Drop queue name env var + SendMessage IAM dependency on frontend. + +If backend auto-chains combiner on splitter completion (backend-task-5), this route may simply proxy a manual "re-combine" action or be removed entirely. Decide during implementation. + + +## Acceptance Criteria + +- [ ] #1 `combine/route.ts` no longer imports `sendToQueue` or reads `BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME` +- [ ] #2 Backend trigger-combiner endpoint called with task_id +- [ ] #3 Frontend still updates subTasks row with inputs on success (or delegates to backend) +- [ ] #4 Decision logged: proxy vs delete-after-auto-chain + diff --git a/backlog/tasks/task-8 - Add-confirm-matches-page-for-bulk-upload-review.md b/backlog/tasks/task-8 - Add-confirm-matches-page-for-bulk-upload-review.md new file mode 100644 index 0000000..1334ca3 --- /dev/null +++ b/backlog/tasks/task-8 - Add-confirm-matches-page-for-bulk-upload-review.md @@ -0,0 +1,34 @@ +--- +id: TASK-8 +title: Add confirm-matches page for bulk upload address→UPRN review +status: To Do +assignee: [] +created_date: '2026-04-20' +updated_date: '2026-04-20' +labels: + - frontend + - bulk-upload + - ui +dependencies: + - TASK-9 + - BACKEND-TASK-3 +priority: high +ordinal: 3000 +--- + +## Description + + +New route `/portfolio/{slug}/bulk-upload/{uploadId}/confirm-matches`. Loads combined CSV from backend (via frontend proxy route, see task-9) and renders review table: original address input | matched UPRN | matched address | confidence. User can accept/reject per row, then POST confirmed rows to backend to persist into portfolio `addresses`. + +Status transitions: `complete` (combiner done) → show "Review matches" CTA on upload detail page → confirm-matches page → on submit, move upload to `confirmed` or similar. + + +## Acceptance Criteria + +- [ ] #1 Page renders match rows in a scrollable table +- [ ] #2 Row actions: accept (default on), reject +- [ ] #3 Submit posts accepted rows to backend confirm-matches route +- [ ] #4 After submit, redirect to portfolio addresses list +- [ ] #5 No useEffect/useMemo (per CLAUDE.md) — use Server Components + Route Handlers where possible + diff --git a/backlog/tasks/task-9 - Add-proxy-api-routes-for-combined-results-and-confirm-matches.md b/backlog/tasks/task-9 - Add-proxy-api-routes-for-combined-results-and-confirm-matches.md new file mode 100644 index 0000000..bcd2145 --- /dev/null +++ b/backlog/tasks/task-9 - Add-proxy-api-routes-for-combined-results-and-confirm-matches.md @@ -0,0 +1,35 @@ +--- +id: TASK-9 +title: Add proxy API routes for combined-results and confirm-matches +status: To Do +assignee: [] +created_date: '2026-04-20' +updated_date: '2026-04-20' +labels: + - frontend + - bulk-upload + - api +dependencies: + - BACKEND-TASK-3 + - BACKEND-TASK-4 +priority: high +ordinal: 4000 +--- + +## Description + + +Two Next.js route handlers that proxy to backend: +- `GET /api/portfolio/{portfolioId}/bulk-uploads/{uploadId}/combined-results` → backend GET `/bulk-uploads/{uploadId}/combined-results`. Returns parsed match rows for the confirm UI. +- `POST /api/portfolio/{portfolioId}/bulk-uploads/{uploadId}/confirm-matches` → backend POST `/bulk-uploads/{uploadId}/confirm-matches`. Body: accepted rows. + +Both must: check session, pass through portfolio scope, translate backend errors to sane frontend responses. + + +## Acceptance Criteria + +- [ ] #1 Both routes auth-gated via getServerSession +- [ ] #2 Params typed as Promise per Next.js 15 convention +- [ ] #3 Backend 4xx/5xx surfaced with appropriate HTTP code + message +- [ ] #4 Upload id is validated against bulk_address_uploads row before proxying + diff --git a/run_backlog_browser.sh b/run_backlog_browser.sh new file mode 100644 index 0000000..bd3fcc4 --- /dev/null +++ b/run_backlog_browser.sh @@ -0,0 +1 @@ +backlog browser diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts new file mode 100644 index 0000000..44f74fa --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts @@ -0,0 +1,67 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + + const [upload] = await db + .select({ taskId: bulkAddressUploads.taskId }) + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!upload.taskId) return NextResponse.json({ error: "Task not started" }, { status: 409 }); + + const fastapiUrl = process.env.FASTAPI_API_URL; + const fastapiKey = process.env.FASTAPI_API_KEY; + if (!fastapiUrl || !fastapiKey) { + console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); + return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); + } + + const sessionToken = + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value; + + const { searchParams } = new URL(request.url); + const offset = searchParams.get("offset") ?? "0"; + const limit = searchParams.get("limit") ?? "500"; + + try { + const res = await fetch( + `${fastapiUrl}/v1/bulk-uploads/${upload.taskId}/combined-results?offset=${offset}&limit=${limit}`, + { + headers: { + "x-api-key": fastapiKey, + Authorization: `Bearer ${sessionToken}`, + }, + } + ); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + console.error("Backend combined-results failed:", res.status, errText); + return NextResponse.json( + { error: res.status === 409 ? "Combiner not finished" : "Failed to fetch results" }, + { status: res.status === 409 ? 409 : 502 } + ); + } + + const data = await res.json(); + return NextResponse.json(data, { status: 200 }); + } catch (err) { + console.error("Failed to reach backend combined-results:", err); + return NextResponse.json({ error: "Failed to fetch results" }, { status: 502 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/confirm-matches/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/confirm-matches/route.ts new file mode 100644 index 0000000..53fb143 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/confirm-matches/route.ts @@ -0,0 +1,70 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + + const [upload] = await db + .select({ taskId: bulkAddressUploads.taskId }) + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!upload.taskId) return NextResponse.json({ error: "Task not started" }, { status: 409 }); + + const fastapiUrl = process.env.FASTAPI_API_URL; + const fastapiKey = process.env.FASTAPI_API_KEY; + if (!fastapiUrl || !fastapiKey) { + console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); + return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); + } + + const sessionToken = + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value; + + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + try { + const res = await fetch( + `${fastapiUrl}/v1/bulk-uploads/${upload.taskId}/confirm-matches`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": fastapiKey, + Authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify(body), + } + ); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + console.error("Backend confirm-matches failed:", res.status, errText); + return NextResponse.json({ error: "Failed to confirm matches" }, { status: 502 }); + } + + const data = await res.json(); + return NextResponse.json(data, { status: 200 }); + } catch (err) { + console.error("Failed to reach backend confirm-matches:", err); + return NextResponse.json({ error: "Failed to confirm matches" }, { status: 502 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts index 5a6d4b0..65c4edb 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts @@ -8,7 +8,6 @@ import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { z } from "zod"; import { createS3Client } from "@/app/utils/s3"; -import { sendToQueue } from "@/app/utils/sqs"; import S3 from "aws-sdk/clients/s3"; import * as XLSX from "xlsx"; @@ -131,20 +130,41 @@ export async function POST( } const s3Uri = `s3://${outputBucket}/${transformedKey}`; - const queueName = process.env.POSTCODE_SPLITTER_QUEUE_NAME; - if (!queueName) { - console.error("POSTCODE_SPLITTER_QUEUE_NAME not set"); + + const fastapiUrl = process.env.FASTAPI_API_URL; + const fastapiKey = process.env.FASTAPI_API_KEY; + if (!fastapiUrl || !fastapiKey) { + console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); } + const sessionToken = + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value; + try { - await sendToQueue( - { task_id: body.taskId, sub_task_id: body.subTaskId, s3_uri: s3Uri }, - { queueName } - ); + const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-splitter`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": fastapiKey, + Authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify({ + task_id: body.taskId, + sub_task_id: body.subTaskId, + s3_uri: s3Uri, + }), + }); + + if (!triggerRes.ok) { + const errText = await triggerRes.text().catch(() => ""); + console.error("Backend trigger-splitter failed:", triggerRes.status, errText); + return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 }); + } } catch (err) { - console.error("Failed to send SQS message:", err); - return NextResponse.json({ error: "Failed to queue onboarding job" }, { status: 500 }); + console.error("Failed to reach backend trigger-splitter:", err); + return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 }); } await Promise.all([ From bee57a56b52fb5c9ed2bd7dea3737b77752b1a20 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 21 Apr 2026 20:24:26 +0000 Subject: [PATCH 05/22] address2uprn onboaridng poc --- .claude/settings.local.json | 11 +- .devcontainer/docker-compose.yml | 3 + .gitignore | 2 + ...s-when-combined_output_s3_uri-populated.md | 31 --- ...- Smoke-test-combiner-end-to-end-on-dev.md | 36 ---- ...-next-time-bulk_address_uploads-touched.md | 28 --- ...-call-backend-trigger-splitter-endpoint.md | 105 ---------- ...-route-to-call-backend-trigger-combiner.md | 32 --- ...irm-matches-page-for-bulk-upload-review.md | 34 --- ...or-combined-results-and-confirm-matches.md | 35 ---- .../bulk-uploads/[uploadId]/combine/route.ts | 34 ++- .../[uploadId]/confirm-matches/route.ts | 70 ------- .../bulk-uploads/[uploadId]/onboard/route.ts | 6 +- .../bulk-uploads/[uploadId]/route.ts | 25 +++ .../upload/bulk-addresses/confirm/route.ts | 7 +- .../portfolio/BulkUploadComingSoonModal.tsx | 6 +- .../[uploadId]/OnboardingProgress.tsx | 60 +++++- .../confirm-matches/ConfirmMatchesClient.tsx | 197 ++++++++++++++++++ .../[uploadId]/confirm-matches/page.tsx | 109 ++++++++++ .../bulk-upload/[uploadId]/page.tsx | 32 ++- 20 files changed, 467 insertions(+), 396 deletions(-) delete mode 100644 backlog/tasks/task-10 - Redirect-to-confirm-matches-when-combined_output_s3_uri-populated.md delete mode 100644 backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md delete mode 100644 backlog/tasks/task-5 - Squash-migrations-0178-DROP-0179-re-ADD-next-time-bulk_address_uploads-touched.md delete mode 100644 backlog/tasks/task-6 - Refactor-onboard-route-to-call-backend-trigger-splitter-endpoint.md delete mode 100644 backlog/tasks/task-7 - Refactor-combine-route-to-call-backend-trigger-combiner.md delete mode 100644 backlog/tasks/task-8 - Add-confirm-matches-page-for-bulk-upload-review.md delete mode 100644 backlog/tasks/task-9 - Add-proxy-api-routes-for-combined-results-and-confirm-matches.md delete mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/confirm-matches/route.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/ConfirmMatchesClient.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b6b929a..dddaa7e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,16 @@ "Bash(backlog mcp *)", "Read(//home/vscode/.config/nvim/**)", "Read(//home/vscode/.config/nvim/lua/plugins/**)", - "Bash(npx tsc *)" + "Bash(npx tsc *)", + "Read(//workspaces/home/github/Model/backend/**)", + "Read(//workspaces/home/github/Model/etl/**)", + "mcp__backlog__task_create", + "mcp__backlog__task_view", + "mcp__backlog__task_edit", + "Read(//workspaces/home/github/Model/**)", + "Bash(pytest backend/tests/test_bulk_combiner_status.py -v --no-cov)", + "Bash(echo \"EXIT: $?\")", + "mcp__backlog__task_list" ] }, "enabledMcpjsonServers": [ diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index ed3c80c..2477976 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -17,6 +17,7 @@ services: - SSH_AUTH_SOCK=${SSH_AUTH_SOCK:-} networks: - frontend-net + - shared-dev pgadmin: image: dpage/pgadmin4 @@ -32,3 +33,5 @@ services: networks: frontend-net: driver: bridge + shared-dev: + external: true diff --git a/.gitignore b/.gitignore index 6fcf0a3..61bcc46 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ cypress.env.json # typescript *.tsbuildinfo next-env.d.ts + +backlog/* diff --git a/backlog/tasks/task-10 - Redirect-to-confirm-matches-when-combined_output_s3_uri-populated.md b/backlog/tasks/task-10 - Redirect-to-confirm-matches-when-combined_output_s3_uri-populated.md deleted file mode 100644 index 3dbff76..0000000 --- a/backlog/tasks/task-10 - Redirect-to-confirm-matches-when-combined_output_s3_uri-populated.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -id: TASK-10 -title: Redirect to confirm-matches when combined_output_s3_uri populated -status: To Do -assignee: [] -created_date: '2026-04-20' -updated_date: '2026-04-20' -labels: - - frontend - - bulk-upload - - ui -dependencies: - - TASK-8 -priority: medium -ordinal: 5000 ---- - -## Description - - -`OnboardingProgress.tsx` currently fires client-side combine POST when task terminal. Once backend auto-chains (backend-task-5) OR frontend triggers via backend route (task-7), the polling should watch for `bulk_address_uploads.combined_output_s3_uri` to be set. When present, show "Review matches →" CTA (or auto-redirect to confirm-matches page, task-8). - -May require a new GET endpoint that returns `{status, combined_output_s3_uri}` for polling. Or extend existing task summary with upload fields. - - -## Acceptance Criteria - -- [ ] #1 Polling stops once combined_output_s3_uri populated -- [ ] #2 UI surfaces a clear CTA to review matches -- [ ] #3 No duplicate combiner fires across refreshes - diff --git a/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md b/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md deleted file mode 100644 index 95911ab..0000000 --- a/backlog/tasks/task-4 - Smoke-test-combiner-end-to-end-on-dev.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -id: TASK-4 -title: Smoke-test full bulk upload flow end-to-end on dev -status: To Do -assignee: - - Jun-te Kim -created_date: '2026-04-18 19:02' -updated_date: '2026-04-20' -labels: - - qa - - bulk-upload -dependencies: - - TASK-6 - - TASK-7 - - TASK-8 - - TASK-9 - - TASK-10 -priority: medium -ordinal: 9000 ---- - -## Description - - -After frontend + backend refactor ships, run the full flow on dev: upload xlsx → map columns → start onboarding → poll task progress → combiner fires → combined_output_s3_uri populated → review matches page renders → confirm rows → addresses appear in portfolio. - - -## Acceptance Criteria - -- [ ] #1 Frontend no longer needs POSTCODE_SPLITTER_QUEUE_NAME or BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME env vars -- [ ] #2 Backend logs show both splitter + combiner triggered via HTTP route -- [ ] #3 bulk_final_outputs/{task_id}/combined_.csv exists in retrofit_sap_data bucket -- [ ] #4 bulk_address_uploads.combined_output_s3_uri populated -- [ ] #5 Confirm-matches page renders with match rows -- [ ] #6 After confirm submit, addresses persist into portfolio - diff --git a/backlog/tasks/task-5 - Squash-migrations-0178-DROP-0179-re-ADD-next-time-bulk_address_uploads-touched.md b/backlog/tasks/task-5 - Squash-migrations-0178-DROP-0179-re-ADD-next-time-bulk_address_uploads-touched.md deleted file mode 100644 index 2d64e04..0000000 --- a/backlog/tasks/task-5 - Squash-migrations-0178-DROP-0179-re-ADD-next-time-bulk_address_uploads-touched.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -id: TASK-5 -title: >- - Squash migrations 0178 (DROP) + 0179 (re-ADD) next time bulk_address_uploads - touched -status: Done -assignee: - - Jun-te Kim -created_date: '2026-04-18 19:02' -updated_date: '2026-04-18 19:06' -labels: - - tech-debt - - db -dependencies: [] -priority: low ---- - -## Description - - -0178 DROPs task_id + combined_output_s3_uri; 0179 re-ADDs them. Net-zero on live, wasted churn on fresh envs. Collapse to single migration next time schema changes in this area. - - -## Implementation Notes - - -Squashed in-place: deleted 0179.sql + 0179_snapshot.json, removed from _journal.json, patched 0178_snapshot.json to include task_id + combined_output_s3_uri cols. Orphan row may remain in live __drizzle_migrations but drizzle tolerates it. - diff --git a/backlog/tasks/task-6 - Refactor-onboard-route-to-call-backend-trigger-splitter-endpoint.md b/backlog/tasks/task-6 - Refactor-onboard-route-to-call-backend-trigger-splitter-endpoint.md deleted file mode 100644 index 310c2fa..0000000 --- a/backlog/tasks/task-6 - Refactor-onboard-route-to-call-backend-trigger-splitter-endpoint.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -id: TASK-6 -title: Refactor onboard route to call backend trigger-splitter endpoint -status: In Progress -assignee: [] -created_date: '2026-04-20' -updated_date: '2026-04-20 12:55' -labels: - - frontend - - bulk-upload - - refactor -dependencies: - - BACKEND-TASK-1 -priority: high -ordinal: 1000 ---- - -## Description - - -Currently `src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts` builds an SQS message and sends it to `POSTCODE_SPLITTER_QUEUE_NAME`. Move the SQS send to the backend. Frontend still transforms XLSX → CSV + uploads to S3, then calls backend HTTP `POST /v1/bulk-uploads/trigger-splitter`. Drop `POSTCODE_SPLITTER_QUEUE_NAME`, `sendToQueue`, and SQS IAM dependency. - - -## Acceptance Criteria - -- [x] #1 `onboard/route.ts` no longer imports `sendToQueue` or reads `POSTCODE_SPLITTER_QUEUE_NAME` -- [x] #2 Transformed CSV still uploaded to `bulk_onboarding_inputs/{portfolioId}/{uploadId}.csv` -- [x] #3 Backend trigger-splitter endpoint called with correct payload -- [ ] #4 DB updates (bulk_address_uploads.status="processing", tasks.status, subTasks.inputs) still happen on success -- [ ] #5 4xx/5xx from backend → return 502 to client with useful message - - -## Implementation Plan - - - - -### File changed -`src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts` - -### What stays the same -- Auth check via `getServerSession` -- Upload record + status validation -- `transformFile()` XLSX → CSV -- S3 upload of transformed CSV to `bulk_onboarding_inputs/{portfolioId}/{uploadId}.csv` -- DB writes: `bulkAddressUploads.status = "processing"`, `tasks.status = "in progress"`, `subTasks.inputs` - -### What changes - -**Remove:** -```ts -import { sendToQueue } from "@/app/utils/sqs"; -// POSTCODE_SPLITTER_QUEUE_NAME env var check -``` - -**Add — call backend trigger-splitter:** -```ts -const fastapiUrl = process.env.FASTAPI_API_URL; -const fastapiKey = process.env.FASTAPI_API_KEY; -if (!fastapiUrl || !fastapiKey) { - return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); -} - -const sessionToken = - request.cookies.get("__Secure-next-auth.session-token")?.value ?? - request.cookies.get("next-auth.session-token")?.value; - -const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-splitter`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": fastapiKey, - Authorization: `Bearer ${sessionToken}`, - }, - body: JSON.stringify({ - task_id: body.taskId, - sub_task_id: body.subTaskId, - s3_uri: s3Uri, - }), -}); - -if (!triggerRes.ok) { - const errText = await triggerRes.text().catch(() => ""); - console.error("Backend trigger-splitter failed:", triggerRes.status, errText); - return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 }); -} -``` - -**Order of ops** (no change to structure, just swap): -1. Validate body + upload record -2. Read source file from S3 -3. Transform XLSX → CSV -4. Upload transformed CSV to S3 -5. ~~sendToQueue~~ → fetch backend trigger-splitter -6. DB updates (status, taskId, subTask inputs) — only after step 5 succeeds - -### Env vars required -- `FASTAPI_API_URL` — already set, already used in `plan/trigger/route.ts` -- `FASTAPI_API_KEY` — already set - -### Env vars removed -- `POSTCODE_SPLITTER_QUEUE_NAME` — no longer read by frontend (can remove from .env.local + staging/prod) - - - diff --git a/backlog/tasks/task-7 - Refactor-combine-route-to-call-backend-trigger-combiner.md b/backlog/tasks/task-7 - Refactor-combine-route-to-call-backend-trigger-combiner.md deleted file mode 100644 index 19ad491..0000000 --- a/backlog/tasks/task-7 - Refactor-combine-route-to-call-backend-trigger-combiner.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -id: TASK-7 -title: Refactor combine route to call backend trigger-combiner endpoint -status: To Do -assignee: [] -created_date: '2026-04-20' -updated_date: '2026-04-20' -labels: - - frontend - - bulk-upload - - refactor -dependencies: - - BACKEND-TASK-2 -priority: high -ordinal: 2000 ---- - -## Description - - -`src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts` sends SQS directly to `BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME`. Replace with call to backend `POST /bulk-uploads/{task_id}/combine`. Drop queue name env var + SendMessage IAM dependency on frontend. - -If backend auto-chains combiner on splitter completion (backend-task-5), this route may simply proxy a manual "re-combine" action or be removed entirely. Decide during implementation. - - -## Acceptance Criteria - -- [ ] #1 `combine/route.ts` no longer imports `sendToQueue` or reads `BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME` -- [ ] #2 Backend trigger-combiner endpoint called with task_id -- [ ] #3 Frontend still updates subTasks row with inputs on success (or delegates to backend) -- [ ] #4 Decision logged: proxy vs delete-after-auto-chain - diff --git a/backlog/tasks/task-8 - Add-confirm-matches-page-for-bulk-upload-review.md b/backlog/tasks/task-8 - Add-confirm-matches-page-for-bulk-upload-review.md deleted file mode 100644 index 1334ca3..0000000 --- a/backlog/tasks/task-8 - Add-confirm-matches-page-for-bulk-upload-review.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -id: TASK-8 -title: Add confirm-matches page for bulk upload address→UPRN review -status: To Do -assignee: [] -created_date: '2026-04-20' -updated_date: '2026-04-20' -labels: - - frontend - - bulk-upload - - ui -dependencies: - - TASK-9 - - BACKEND-TASK-3 -priority: high -ordinal: 3000 ---- - -## Description - - -New route `/portfolio/{slug}/bulk-upload/{uploadId}/confirm-matches`. Loads combined CSV from backend (via frontend proxy route, see task-9) and renders review table: original address input | matched UPRN | matched address | confidence. User can accept/reject per row, then POST confirmed rows to backend to persist into portfolio `addresses`. - -Status transitions: `complete` (combiner done) → show "Review matches" CTA on upload detail page → confirm-matches page → on submit, move upload to `confirmed` or similar. - - -## Acceptance Criteria - -- [ ] #1 Page renders match rows in a scrollable table -- [ ] #2 Row actions: accept (default on), reject -- [ ] #3 Submit posts accepted rows to backend confirm-matches route -- [ ] #4 After submit, redirect to portfolio addresses list -- [ ] #5 No useEffect/useMemo (per CLAUDE.md) — use Server Components + Route Handlers where possible - diff --git a/backlog/tasks/task-9 - Add-proxy-api-routes-for-combined-results-and-confirm-matches.md b/backlog/tasks/task-9 - Add-proxy-api-routes-for-combined-results-and-confirm-matches.md deleted file mode 100644 index bcd2145..0000000 --- a/backlog/tasks/task-9 - Add-proxy-api-routes-for-combined-results-and-confirm-matches.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -id: TASK-9 -title: Add proxy API routes for combined-results and confirm-matches -status: To Do -assignee: [] -created_date: '2026-04-20' -updated_date: '2026-04-20' -labels: - - frontend - - bulk-upload - - api -dependencies: - - BACKEND-TASK-3 - - BACKEND-TASK-4 -priority: high -ordinal: 4000 ---- - -## Description - - -Two Next.js route handlers that proxy to backend: -- `GET /api/portfolio/{portfolioId}/bulk-uploads/{uploadId}/combined-results` → backend GET `/bulk-uploads/{uploadId}/combined-results`. Returns parsed match rows for the confirm UI. -- `POST /api/portfolio/{portfolioId}/bulk-uploads/{uploadId}/confirm-matches` → backend POST `/bulk-uploads/{uploadId}/confirm-matches`. Body: accepted rows. - -Both must: check session, pass through portfolio scope, translate backend errors to sane frontend responses. - - -## Acceptance Criteria - -- [ ] #1 Both routes auth-gated via getServerSession -- [ ] #2 Params typed as Promise per Next.js 15 convention -- [ ] #3 Backend 4xx/5xx surfaced with appropriate HTTP code + message -- [ ] #4 Upload id is validated against bulk_address_uploads row before proxying - diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts index 1ebb20b..673e2c5 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts @@ -5,10 +5,9 @@ import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { sendToQueue } from "@/app/utils/sqs"; export async function POST( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } ) { const session = await getServerSession(AuthOptions); @@ -28,9 +27,10 @@ export async function POST( if (upload.combinedOutputS3Uri) return NextResponse.json({ alreadyCombined: true }, { status: 200 }); - const queueName = process.env.BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME; - if (!queueName) { - console.error("BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME not set"); + const fastapiUrl = process.env.FASTAPI_API_URL; + const fastapiKey = process.env.FASTAPI_API_KEY; + if (!fastapiUrl || !fastapiKey) { + console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); } @@ -44,11 +44,29 @@ export async function POST( const messageBody = { task_id: upload.taskId, sub_task_id: subTask.id }; + const sessionToken = + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value; + try { - await sendToQueue(messageBody, { queueName }); + const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-combiner`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": fastapiKey, + Authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify(messageBody), + }); + + if (!triggerRes.ok) { + const errText = await triggerRes.text().catch(() => ""); + console.error("Backend trigger-combiner failed:", triggerRes.status, errText); + return NextResponse.json({ error: "Failed to trigger combiner" }, { status: 502 }); + } } catch (err) { - console.error("Failed to send combiner SQS message:", err); - return NextResponse.json({ error: "Failed to queue combiner job" }, { status: 500 }); + console.error("Failed to reach backend trigger-combiner:", err); + return NextResponse.json({ error: "Failed to trigger combiner" }, { status: 502 }); } await db diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/confirm-matches/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/confirm-matches/route.ts deleted file mode 100644 index 53fb143..0000000 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/confirm-matches/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } -) { - const session = await getServerSession(AuthOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const { uploadId } = await params; - - const [upload] = await db - .select({ taskId: bulkAddressUploads.taskId }) - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (!upload.taskId) return NextResponse.json({ error: "Task not started" }, { status: 409 }); - - const fastapiUrl = process.env.FASTAPI_API_URL; - const fastapiKey = process.env.FASTAPI_API_KEY; - if (!fastapiUrl || !fastapiKey) { - console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); - return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); - } - - const sessionToken = - request.cookies.get("__Secure-next-auth.session-token")?.value ?? - request.cookies.get("next-auth.session-token")?.value; - - let body; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - try { - const res = await fetch( - `${fastapiUrl}/v1/bulk-uploads/${upload.taskId}/confirm-matches`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": fastapiKey, - Authorization: `Bearer ${sessionToken}`, - }, - body: JSON.stringify(body), - } - ); - - if (!res.ok) { - const errText = await res.text().catch(() => ""); - console.error("Backend confirm-matches failed:", res.status, errText); - return NextResponse.json({ error: "Failed to confirm matches" }, { status: 502 }); - } - - const data = await res.json(); - return NextResponse.json(data, { status: 200 }); - } catch (err) { - console.error("Failed to reach backend confirm-matches:", err); - return NextResponse.json({ error: "Failed to confirm matches" }, { status: 502 }); - } -} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts index 65c4edb..e5b77b2 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts @@ -143,7 +143,7 @@ export async function POST( request.cookies.get("next-auth.session-token")?.value; try { - const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-splitter`, { + const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-postcode-splitter`, { method: "POST", headers: { "Content-Type": "application/json", @@ -159,11 +159,11 @@ export async function POST( if (!triggerRes.ok) { const errText = await triggerRes.text().catch(() => ""); - console.error("Backend trigger-splitter failed:", triggerRes.status, errText); + console.error("Backend trigger-postcode-splitter failed:", triggerRes.status, errText); return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 }); } } catch (err) { - console.error("Failed to reach backend trigger-splitter:", err); + console.error("Failed to reach backend trigger-postcode-splitter:", err); return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 }); } diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts index b61e05d..f51bd73 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts @@ -2,12 +2,37 @@ import { db } from "@/app/db/db"; import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { z } from "zod"; const PatchSchema = z.object({ columnMapping: z.record(z.string(), z.string()), }); +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + + const [upload] = await db + .select({ + status: bulkAddressUploads.status, + combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri, + }) + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + return NextResponse.json(upload, { status: 200 }); +} + export async function PATCH( request: NextRequest, { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } diff --git a/src/app/api/upload/bulk-addresses/confirm/route.ts b/src/app/api/upload/bulk-addresses/confirm/route.ts index edc6357..8edaab4 100644 --- a/src/app/api/upload/bulk-addresses/confirm/route.ts +++ b/src/app/api/upload/bulk-addresses/confirm/route.ts @@ -8,7 +8,12 @@ const BodySchema = z.object({ filename: z.string(), portfolioId: z.string(), userId: z.string(), - sourceHeaders: z.array(z.string()).default([]), + sourceHeaders: z + .array(z.union([z.string(), z.null(), z.undefined()])) + .default([]) + .transform((arr) => + arr.filter((h): h is string => typeof h === "string" && h.trim().length > 0) + ), }); export async function POST(request: NextRequest) { diff --git a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx index 1cd47bf..a416f00 100644 --- a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx +++ b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx @@ -77,10 +77,12 @@ async function validateHeaders(file: File): Promise<{ error: string | null; head const buffer = await file.arrayBuffer(); const wb = XLSX.read(buffer, { sheetRows: 1 }); const sheet = wb.Sheets[wb.SheetNames[0]]; - const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }); - headers = ((rows[0] as string[]) ?? []).map((h) => String(h ?? "").trim()); + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: "" }); + headers = ((rows[0] as unknown[]) ?? []).map((h) => String(h ?? "").trim()); } + headers = headers.filter((h) => h.length > 0); + const normalised = headers.map((h) => h.toLowerCase()); const hasAddress = normalised.some((h) => h.startsWith("address")); const hasPostcode = normalised.some((h) => h === "postcode"); diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index a4afb30..7effd7f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/navigation"; import Link from "next/link"; interface TaskData { @@ -12,6 +13,11 @@ interface TaskData { failedSubtasks: number; } +interface UploadStatus { + status: string; + combinedOutputS3Uri: string | null; +} + interface Props { taskId: string; portfolioSlug: string; @@ -30,10 +36,13 @@ export default function OnboardingProgress({ uploadId, isDomnaUser, }: Props) { + const router = useRouter(); const [data, setData] = useState(null); + const [uploadStatus, setUploadStatus] = useState(null); const [fetchError, setFetchError] = useState(false); const intervalRef = useRef | null>(null); const combineFiredRef = useRef(false); + const redirectedRef = useRef(false); useEffect(() => { async function poll() { @@ -43,14 +52,30 @@ export default function OnboardingProgress({ const json: TaskData = await res.json(); setData(json); const status = json.status.toLowerCase(); + if (TERMINAL_STATUSES.has(status)) { - if (intervalRef.current) clearInterval(intervalRef.current); if (!FAILED_STATUSES.has(status) && !combineFiredRef.current) { combineFiredRef.current = true; fetch(`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/combine`, { method: "POST", }).catch((err) => console.error("Failed to trigger combiner:", err)); } + + const uploadRes = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}` + ); + if (uploadRes.ok) { + const upload: UploadStatus = await uploadRes.json(); + setUploadStatus(upload); + if (upload.status === "awaiting_review" && !redirectedRef.current) { + redirectedRef.current = true; + if (intervalRef.current) clearInterval(intervalRef.current); + router.push( + `/portfolio/${portfolioSlug}/bulk-upload/${uploadId}/confirm-matches` + ); + return; + } + } } } catch { setFetchError(true); @@ -60,7 +85,7 @@ export default function OnboardingProgress({ poll(); intervalRef.current = setInterval(poll, 3000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [taskId, portfolioId, uploadId]); + }, [taskId, portfolioId, portfolioSlug, uploadId, router]); if (fetchError) return null; if (!data) { @@ -76,12 +101,15 @@ export default function OnboardingProgress({ const complete = data.completedSubtasks; const failed = data.failedSubtasks; const percent = total > 0 ? Math.round((complete / total) * 100) : 0; - const isDone = TERMINAL_STATUSES.has(data.status.toLowerCase()); - const isFailed = ["failed", "failure", "error"].includes(data.status.toLowerCase()); + const taskDone = TERMINAL_STATUSES.has(data.status.toLowerCase()); + const isFailed = FAILED_STATUSES.has(data.status.toLowerCase()); + const isCombining = + taskDone && !isFailed && uploadStatus?.status === "combining"; + const isAwaitingReview = + taskDone && !isFailed && uploadStatus?.status === "awaiting_review"; return (
- {/* Progress bar */}
- {/* Counts */}
{total > 0 && ( @@ -102,20 +129,35 @@ export default function OnboardingProgress({ {failed} failed )} - {!isDone && ( + {!taskDone && ( Running )} - {isDone && !isFailed && ( + {isCombining && ( + + + Combining results… + + )} + {isAwaitingReview && ( - Complete + Ready for review )}
+ {isAwaitingReview && ( + + Review matches + + )} + {isDomnaUser && ( r.flags.includes(filter)); + + const basePath = `/portfolio/${slug}/bulk-upload/${uploadId}/confirm-matches`; + const pageStart = data.total === 0 ? 0 : offset + 1; + const pageEnd = Math.min(offset + data.rows.length, data.total); + const hasPrev = offset > 0; + const hasNext = offset + limit < data.total; + const prevOffset = Math.max(0, offset - limit); + const nextOffset = offset + limit; + + return ( +
+
+ + All ({data.total}) + + + Missing ({data.flags_summary.missing}) + + + Duplicates ({data.flags_summary.duplicates}) + +
+ +
+ + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((row) => ( + + + + + + + + + + ))} + +
Internal RefInput AddressUPRNMatched AddressScoreFlagsActions
+ No rows match this filter. +
{row.internal_reference ?? "—"}{row.input_address || "—"}{row.uprn ?? "—"}{row.matched_address ?? "—"} + + {scoreChipLabel(row.score_bucket)} + + +
+ {row.flags.map((f) => ( + + {f === "missing" ? "Missing" : "Duplicate"} + + ))} + {row.flags.length === 0 && } +
+
+ +
+
+ +
+ + Showing {pageStart}–{pageEnd} of {data.total} + +
+ {hasPrev ? ( + + Prev + + ) : ( + + Prev + + )} + {hasNext ? ( + + Next + + ) : ( + + Next + + )} +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx new file mode 100644 index 0000000..9cfac38 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx @@ -0,0 +1,109 @@ +"use server"; + +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { eq } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { redirect, notFound } from "next/navigation"; +import { cookies, headers } from "next/headers"; +import Link from "next/link"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import ConfirmMatchesClient, { + CombinedResultsResponse, +} from "./ConfirmMatchesClient"; + +const DEFAULT_LIMIT = 100; + +export default async function ConfirmMatchesPage(props: { + params: Promise<{ slug: string; uploadId: string }>; + searchParams: Promise<{ offset?: string; limit?: string; filter?: string }>; +}) { + const { slug, uploadId } = await props.params; + const search = await props.searchParams; + const session = await getServerSession(AuthOptions); + if (!session) redirect("/login"); + + const [upload] = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) notFound(); + if (upload.status !== "awaiting_review") { + redirect(`/portfolio/${slug}/bulk-upload/${uploadId}`); + } + + const offset = Math.max(0, parseInt(search.offset ?? "0", 10) || 0); + const limit = Math.max(1, Math.min(500, parseInt(search.limit ?? `${DEFAULT_LIMIT}`, 10) || DEFAULT_LIMIT)); + const filter = search.filter === "missing" || search.filter === "duplicate" ? search.filter : "all"; + + const h = await headers(); + const host = h.get("host"); + const proto = h.get("x-forwarded-proto") ?? "http"; + const cookieStore = await cookies(); + const cookieHeader = cookieStore.getAll().map((c) => `${c.name}=${c.value}`).join("; "); + + const url = `${proto}://${host}/api/portfolio/${upload.portfolioId}/bulk-uploads/${uploadId}/combined-results?offset=${offset}&limit=${limit}`; + + let data: CombinedResultsResponse | null = null; + let fetchError: string | null = null; + try { + const res = await fetch(url, { headers: { Cookie: cookieHeader }, cache: "no-store" }); + if (!res.ok) { + fetchError = `Failed to load results (${res.status})`; + } else { + data = (await res.json()) as CombinedResultsResponse; + } + } catch (err) { + console.error("Failed to fetch combined-results:", err); + fetchError = "Failed to load results"; + } + + return ( +
+ + + Back to upload + + +
+

+ Review matches +

+

+ {upload.filename} +

+ {data && ( +

+ {data.total} addresses ·{" "} + {data.flags_summary.duplicates} duplicates ·{" "} + {data.flags_summary.missing} missing ·{" "} + {data.flags_summary.matched} matched +

+ )} +
+ + {fetchError && ( +
+ {fetchError} +
+ )} + + {data && ( + + )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx index 5f7ca9b..438b9ad 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx @@ -53,6 +53,22 @@ const STATUS_CONFIG = { body: "Your file is currently being processed. This may take a few minutes.", cta: false, }, + combining: { + icon: ArrowPathIcon, + iconBg: "bg-blue-50", + iconColor: "text-blue-500", + title: "Combining results…", + body: "Your matched addresses are being assembled. Almost ready for review.", + cta: false, + }, + awaiting_review: { + icon: CheckCircleIcon, + iconBg: "bg-green-50", + iconColor: "text-green-500", + title: "Ready for review", + body: "Your matches are ready. Review and confirm before finalising onboarding.", + cta: false, + }, complete: { icon: CheckCircleIcon, iconBg: "bg-green-50", @@ -150,7 +166,11 @@ export default async function BulkUploadDetailPage(props: {
)} - {(statusKey === "processing" || statusKey === "complete" || statusKey === "failed") && + {(statusKey === "processing" || + statusKey === "combining" || + statusKey === "awaiting_review" || + statusKey === "complete" || + statusKey === "failed") && upload.taskId && ( )} + + {statusKey === "awaiting_review" && ( + + Review matches + + + )}
From 4cdb21fbbc5c91de0c7d0c7be16d811ab4105f3c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 23 Apr 2026 12:01:17 +0000 Subject: [PATCH 06/22] save current changes --- .claude/settings.local.json | 4 +- .devcontainer/docker-compose.yml | 1 + .../[uploadId]/combined-results/route.ts | 173 ++++++++++++++---- .../[uploadId]/OnboardingProgress.tsx | 9 - .../[uploadId]/confirm-matches/page.tsx | 8 +- 5 files changed, 146 insertions(+), 49 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dddaa7e..6aad418 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,9 @@ "Read(//workspaces/home/github/Model/**)", "Bash(pytest backend/tests/test_bulk_combiner_status.py -v --no-cov)", "Bash(echo \"EXIT: $?\")", - "mcp__backlog__task_list" + "mcp__backlog__task_list", + "Bash(grep -E \"\\\\.\\(prisma|sql|ts\\)$\")", + "Bash(xargs cat *)" ] }, "enabledMcpjsonServers": [ diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 2477976..e42032b 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -35,3 +35,4 @@ networks: driver: bridge shared-dev: external: true + name: shared-dev diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts index 44f74fa..d0cf1ca 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts @@ -4,6 +4,50 @@ import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import S3 from "aws-sdk/clients/s3"; +import * as XLSX from "xlsx"; + +const ADDRESS_COLS = ["Address 1", "Address 2", "Address 3", "postcode"] as const; +const INTERNAL_REF_COL = "Internal Reference"; +const UPRN_COL = "address2uprn_uprn"; +const MATCHED_ADDRESS_COL = "address2uprn_address"; +const LEXISCORE_COL = "address2uprn_lexiscore"; +const MISSING_SENTINEL = "invalid postcode"; +const HIGH_THRESHOLD = 0.85; +const MED_THRESHOLD = 0.65; + +type ScoreBucket = "high" | "med" | "low" | null; + +function scoreBucket(score: number | null): ScoreBucket { + if (score === null) return null; + if (score >= HIGH_THRESHOLD) return "high"; + if (score >= MED_THRESHOLD) return "med"; + return "low"; +} + +function normalize(v: unknown): string { + if (v === null || v === undefined) return ""; + return String(v).trim(); +} + +function isMissingUprn(uprn: string): boolean { + return uprn === "" || uprn.toLowerCase() === MISSING_SENTINEL; +} + +function parseLexiscore(raw: unknown): number | null { + const val = normalize(raw); + if (!val || val.toLowerCase() === MISSING_SENTINEL) return null; + const n = Number(val); + return Number.isFinite(n) ? n : null; +} + +function parseS3Uri(uri: string): { bucket: string; key: string } | null { + if (!uri.startsWith("s3://")) return null; + const rest = uri.slice(5); + const slash = rest.indexOf("/"); + if (slash < 0) return null; + return { bucket: rest.slice(0, slash), key: rest.slice(slash + 1) }; +} export async function GET( request: NextRequest, @@ -15,53 +59,108 @@ export async function GET( const { uploadId } = await params; const [upload] = await db - .select({ taskId: bulkAddressUploads.taskId }) + .select({ + combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri, + }) .from(bulkAddressUploads) .where(eq(bulkAddressUploads.id, uploadId)) .limit(1); if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (!upload.taskId) return NextResponse.json({ error: "Task not started" }, { status: 409 }); + if (!upload.combinedOutputS3Uri) + return NextResponse.json({ error: "Combiner not finished" }, { status: 409 }); - const fastapiUrl = process.env.FASTAPI_API_URL; - const fastapiKey = process.env.FASTAPI_API_KEY; - if (!fastapiUrl || !fastapiKey) { - console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); - return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); - } - - const sessionToken = - request.cookies.get("__Secure-next-auth.session-token")?.value ?? - request.cookies.get("next-auth.session-token")?.value; + const parsed = parseS3Uri(upload.combinedOutputS3Uri); + if (!parsed) + return NextResponse.json({ error: "Invalid combined output S3 URI" }, { status: 500 }); const { searchParams } = new URL(request.url); - const offset = searchParams.get("offset") ?? "0"; - const limit = searchParams.get("limit") ?? "500"; + const offset = Math.max(0, parseInt(searchParams.get("offset") ?? "0", 10) || 0); + const limit = Math.max(1, Math.min(5000, parseInt(searchParams.get("limit") ?? "500", 10) || 500)); + const s3 = new S3({ + region: process.env.RETROFIT_DATA_DEV_REGION, + accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, + secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, + }); + + let rawRows: Record[]; try { - const res = await fetch( - `${fastapiUrl}/v1/bulk-uploads/${upload.taskId}/combined-results?offset=${offset}&limit=${limit}`, - { - headers: { - "x-api-key": fastapiKey, - Authorization: `Bearer ${sessionToken}`, - }, - } - ); - - if (!res.ok) { - const errText = await res.text().catch(() => ""); - console.error("Backend combined-results failed:", res.status, errText); - return NextResponse.json( - { error: res.status === 409 ? "Combiner not finished" : "Failed to fetch results" }, - { status: res.status === 409 ? 409 : 502 } - ); - } - - const data = await res.json(); - return NextResponse.json(data, { status: 200 }); + const obj = await s3 + .getObject({ Bucket: parsed.bucket, Key: parsed.key }) + .promise(); + const buf = Buffer.from(obj.Body as Uint8Array); + const wb = XLSX.read(buf, { type: "buffer" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); } catch (err) { - console.error("Failed to reach backend combined-results:", err); - return NextResponse.json({ error: "Failed to fetch results" }, { status: 502 }); + console.error("Failed to read combined CSV from S3:", err); + return NextResponse.json({ error: "Failed to read combined CSV" }, { status: 502 }); } + + const uprnValues = rawRows.map((r) => normalize(r[UPRN_COL])); + const uprnCounts = new Map(); + for (const u of uprnValues) { + if (isMissingUprn(u)) continue; + uprnCounts.set(u, (uprnCounts.get(u) ?? 0) + 1); + } + const duplicateUprns = new Set( + Array.from(uprnCounts.entries()) + .filter(([, c]) => c >= 2) + .map(([u]) => u) + ); + + const missingCount = uprnValues.filter(isMissingUprn).length; + const duplicateCount = uprnValues.filter((u) => duplicateUprns.has(u)).length; + const matchedCount = rawRows.length - missingCount; + + const page = rawRows.slice(offset, offset + limit); + const rows = page.map((raw, i) => { + const rowIndex = offset + i; + const addressParts = ADDRESS_COLS.map((c) => normalize(raw[c])).filter(Boolean); + const inputAddress = addressParts.join(", "); + const internalRef = normalize(raw[INTERNAL_REF_COL]) || null; + + const uprnRaw = normalize(raw[UPRN_COL]); + const uprn = isMissingUprn(uprnRaw) ? null : uprnRaw; + + const matchedAddressRaw = normalize(raw[MATCHED_ADDRESS_COL]); + const matchedAddress = + !matchedAddressRaw || matchedAddressRaw.toLowerCase() === MISSING_SENTINEL + ? null + : matchedAddressRaw; + + const lexiscore = parseLexiscore(raw[LEXISCORE_COL]); + + const flags: ("duplicate" | "missing")[] = []; + if (uprn === null) flags.push("missing"); + else if (duplicateUprns.has(uprn)) flags.push("duplicate"); + + return { + row_index: rowIndex, + input_address: inputAddress, + internal_reference: internalRef, + uprn, + matched_address: matchedAddress, + lexiscore, + score_bucket: scoreBucket(lexiscore), + flags, + }; + }); + + return NextResponse.json( + { + task_id: uploadId, + total: rawRows.length, + offset, + limit, + flags_summary: { + duplicates: duplicateCount, + missing: missingCount, + matched: matchedCount, + }, + rows, + }, + { status: 200 } + ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index 7effd7f..2eae693 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -149,15 +149,6 @@ export default function OnboardingProgress({ )}
- {isAwaitingReview && ( - - Review matches - - )} - {isDomnaUser && ( ({})); + const upstreamStatus = body?.upstreamStatus; + const upstreamBody = body?.upstreamBody; + fetchError = `Failed to load results (${res.status})${upstreamStatus ? ` · upstream ${upstreamStatus}` : ""}${upstreamBody ? ` · ${upstreamBody}` : ""}`; + console.error("Confirm-matches fetch error:", { status: res.status, body }); } else { data = (await res.json()) as CombinedResultsResponse; } } catch (err) { console.error("Failed to fetch combined-results:", err); - fetchError = "Failed to load results"; + fetchError = `Failed to load results · ${err instanceof Error ? err.message : String(err)}`; } return ( From c3cc123a6fc2cda4f03b3580583a6eaa15942e7c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 24 Apr 2026 15:57:30 +0000 Subject: [PATCH 07/22] save working progress --- .claude/settings.local.json | 3 +- .../[uploadId]/combined-results/route.ts | 166 --------------- .../bulk-uploads/[uploadId]/finalize/route.ts | 193 +++++++++++++++++ src/app/db/schema/property.ts | 1 + .../[uploadId]/OnboardingProgress.tsx | 72 ++++++- .../confirm-matches/ConfirmMatchesClient.tsx | 197 ------------------ .../[uploadId]/confirm-matches/page.tsx | 113 ---------- .../bulk-upload/[uploadId]/page.tsx | 28 ++- .../[slug]/components/PropertyTable.tsx | 2 +- .../components/propertyTableColumns.tsx | 19 ++ src/app/portfolio/[slug]/utils.ts | 6 +- 11 files changed, 299 insertions(+), 501 deletions(-) delete mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts delete mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/ConfirmMatchesClient.tsx delete mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6aad418..33ffcd4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(echo \"EXIT: $?\")", "mcp__backlog__task_list", "Bash(grep -E \"\\\\.\\(prisma|sql|ts\\)$\")", - "Bash(xargs cat *)" + "Bash(xargs cat *)", + "Bash(node -e ' *)" ] }, "enabledMcpjsonServers": [ diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts deleted file mode 100644 index d0cf1ca..0000000 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import S3 from "aws-sdk/clients/s3"; -import * as XLSX from "xlsx"; - -const ADDRESS_COLS = ["Address 1", "Address 2", "Address 3", "postcode"] as const; -const INTERNAL_REF_COL = "Internal Reference"; -const UPRN_COL = "address2uprn_uprn"; -const MATCHED_ADDRESS_COL = "address2uprn_address"; -const LEXISCORE_COL = "address2uprn_lexiscore"; -const MISSING_SENTINEL = "invalid postcode"; -const HIGH_THRESHOLD = 0.85; -const MED_THRESHOLD = 0.65; - -type ScoreBucket = "high" | "med" | "low" | null; - -function scoreBucket(score: number | null): ScoreBucket { - if (score === null) return null; - if (score >= HIGH_THRESHOLD) return "high"; - if (score >= MED_THRESHOLD) return "med"; - return "low"; -} - -function normalize(v: unknown): string { - if (v === null || v === undefined) return ""; - return String(v).trim(); -} - -function isMissingUprn(uprn: string): boolean { - return uprn === "" || uprn.toLowerCase() === MISSING_SENTINEL; -} - -function parseLexiscore(raw: unknown): number | null { - const val = normalize(raw); - if (!val || val.toLowerCase() === MISSING_SENTINEL) return null; - const n = Number(val); - return Number.isFinite(n) ? n : null; -} - -function parseS3Uri(uri: string): { bucket: string; key: string } | null { - if (!uri.startsWith("s3://")) return null; - const rest = uri.slice(5); - const slash = rest.indexOf("/"); - if (slash < 0) return null; - return { bucket: rest.slice(0, slash), key: rest.slice(slash + 1) }; -} - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } -) { - const session = await getServerSession(AuthOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const { uploadId } = await params; - - const [upload] = await db - .select({ - combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri, - }) - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (!upload.combinedOutputS3Uri) - return NextResponse.json({ error: "Combiner not finished" }, { status: 409 }); - - const parsed = parseS3Uri(upload.combinedOutputS3Uri); - if (!parsed) - return NextResponse.json({ error: "Invalid combined output S3 URI" }, { status: 500 }); - - const { searchParams } = new URL(request.url); - const offset = Math.max(0, parseInt(searchParams.get("offset") ?? "0", 10) || 0); - const limit = Math.max(1, Math.min(5000, parseInt(searchParams.get("limit") ?? "500", 10) || 500)); - - const s3 = new S3({ - region: process.env.RETROFIT_DATA_DEV_REGION, - accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, - secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, - }); - - let rawRows: Record[]; - try { - const obj = await s3 - .getObject({ Bucket: parsed.bucket, Key: parsed.key }) - .promise(); - const buf = Buffer.from(obj.Body as Uint8Array); - const wb = XLSX.read(buf, { type: "buffer" }); - const sheet = wb.Sheets[wb.SheetNames[0]]; - rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); - } catch (err) { - console.error("Failed to read combined CSV from S3:", err); - return NextResponse.json({ error: "Failed to read combined CSV" }, { status: 502 }); - } - - const uprnValues = rawRows.map((r) => normalize(r[UPRN_COL])); - const uprnCounts = new Map(); - for (const u of uprnValues) { - if (isMissingUprn(u)) continue; - uprnCounts.set(u, (uprnCounts.get(u) ?? 0) + 1); - } - const duplicateUprns = new Set( - Array.from(uprnCounts.entries()) - .filter(([, c]) => c >= 2) - .map(([u]) => u) - ); - - const missingCount = uprnValues.filter(isMissingUprn).length; - const duplicateCount = uprnValues.filter((u) => duplicateUprns.has(u)).length; - const matchedCount = rawRows.length - missingCount; - - const page = rawRows.slice(offset, offset + limit); - const rows = page.map((raw, i) => { - const rowIndex = offset + i; - const addressParts = ADDRESS_COLS.map((c) => normalize(raw[c])).filter(Boolean); - const inputAddress = addressParts.join(", "); - const internalRef = normalize(raw[INTERNAL_REF_COL]) || null; - - const uprnRaw = normalize(raw[UPRN_COL]); - const uprn = isMissingUprn(uprnRaw) ? null : uprnRaw; - - const matchedAddressRaw = normalize(raw[MATCHED_ADDRESS_COL]); - const matchedAddress = - !matchedAddressRaw || matchedAddressRaw.toLowerCase() === MISSING_SENTINEL - ? null - : matchedAddressRaw; - - const lexiscore = parseLexiscore(raw[LEXISCORE_COL]); - - const flags: ("duplicate" | "missing")[] = []; - if (uprn === null) flags.push("missing"); - else if (duplicateUprns.has(uprn)) flags.push("duplicate"); - - return { - row_index: rowIndex, - input_address: inputAddress, - internal_reference: internalRef, - uprn, - matched_address: matchedAddress, - lexiscore, - score_bucket: scoreBucket(lexiscore), - flags, - }; - }); - - return NextResponse.json( - { - task_id: uploadId, - total: rawRows.length, - offset, - limit, - flags_summary: { - duplicates: duplicateCount, - missing: missingCount, - matched: matchedCount, - }, - rows, - }, - { status: 200 } - ); -} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts new file mode 100644 index 0000000..98fb5d1 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts @@ -0,0 +1,193 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { property } from "@/app/db/schema/property"; +import { eq, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import S3 from "aws-sdk/clients/s3"; +import * as XLSX from "xlsx"; + +const ADDRESS_COLS = ["Address 1", "Address 2", "Address 3"] as const; +const POSTCODE_COL = "postcode"; +const INTERNAL_REF_COL = "Internal Reference"; +const UPRN_COL = "address2uprn_uprn"; +const MATCHED_ADDRESS_COL = "address2uprn_address"; +const LEXISCORE_COL = "address2uprn_lexiscore"; +const MISSING_SENTINEL = "invalid postcode"; +const UK_POSTCODE_RE = /[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i; + +function normalize(v: unknown): string { + if (v === null || v === undefined) return ""; + return String(v).trim(); +} + +function isMissing(v: string): boolean { + return v === "" || v.toLowerCase() === MISSING_SENTINEL; +} + +function parseUprn(raw: unknown): bigint | null { + const v = normalize(raw); + if (isMissing(v)) return null; + try { + return BigInt(v); + } catch { + return null; + } +} + +function parseLexiscore(raw: unknown): number | null { + const v = normalize(raw); + if (isMissing(v)) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +function extractPostcode(matched: string | null, fallback: string): string | null { + if (matched) { + const m = matched.match(UK_POSTCODE_RE); + if (m) return m[0].toUpperCase(); + } + return fallback || null; +} + +function parseS3Uri(uri: string): { bucket: string; key: string } | null { + if (!uri.startsWith("s3://")) return null; + const rest = uri.slice(5); + const slash = rest.indexOf("/"); + if (slash < 0) return null; + return { bucket: rest.slice(0, slash), key: rest.slice(slash + 1) }; +} + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + + const [upload] = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (upload.status === "complete" || upload.status === "needs_review") { + return NextResponse.json({ alreadyComplete: true, status: upload.status }, { status: 200 }); + } + if (upload.status !== "awaiting_review") { + return NextResponse.json({ error: "Upload not ready to finalize" }, { status: 422 }); + } + if (!upload.combinedOutputS3Uri) { + return NextResponse.json({ error: "Combiner not finished" }, { status: 409 }); + } + + const parsed = parseS3Uri(upload.combinedOutputS3Uri); + if (!parsed) { + return NextResponse.json({ error: "Invalid combined output S3 URI" }, { status: 500 }); + } + + const s3 = new S3({ + region: process.env.RETROFIT_DATA_DEV_REGION, + accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, + secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, + }); + + let rawRows: Record[]; + try { + const obj = await s3 + .getObject({ Bucket: parsed.bucket, Key: parsed.key }) + .promise(); + const buf = Buffer.from(obj.Body as Uint8Array); + const wb = XLSX.read(buf, { type: "buffer" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); + } catch (err) { + console.error("Failed to read combined CSV from S3:", err); + return NextResponse.json({ error: "Failed to read combined CSV" }, { status: 502 }); + } + + const portfolioIdBig = BigInt(upload.portfolioId); + + const uprnCounts = new Map(); + for (const r of rawRows) { + const v = normalize(r[UPRN_COL]); + if (isMissing(v)) continue; + uprnCounts.set(v, (uprnCounts.get(v) ?? 0) + 1); + } + + let missingUprnCount = 0; + let duplicateUprnCount = 0; + + const values = rawRows.map((raw) => { + const userInputtedAddress = + ADDRESS_COLS.map((c) => normalize(raw[c])).filter(Boolean).join(", ") || null; + const userInputtedPostcode = normalize(raw[POSTCODE_COL]) || null; + + const uprn = parseUprn(raw[UPRN_COL]); + if (uprn === null) missingUprnCount++; + else if ((uprnCounts.get(uprn.toString()) ?? 0) >= 2) duplicateUprnCount++; + + const matchedAddressRaw = normalize(raw[MATCHED_ADDRESS_COL]); + const matchedAddress = isMissing(matchedAddressRaw) ? null : matchedAddressRaw; + + const address = matchedAddress ?? userInputtedAddress; + const postcode = extractPostcode(matchedAddress, userInputtedPostcode ?? ""); + + const internalRef = normalize(raw[INTERNAL_REF_COL]) || null; + const lexiscore = parseLexiscore(raw[LEXISCORE_COL]); + + return { + portfolioId: portfolioIdBig, + creationStatus: "READY" as const, + uprn, + landlordPropertyId: internalRef, + address, + postcode, + userInputtedAddress, + userInputtedPostcode, + lexiscore, + }; + }); + + let inserted = 0; + try { + if (values.length > 0) { + const result = await db + .insert(property) + .values(values) + .onConflictDoNothing({ + target: [property.portfolioId, property.uprn], + where: sql`${property.uprn} IS NOT NULL`, + }) + .returning({ id: property.id }); + inserted = result.length; + } + + const needsReview = missingUprnCount > 0 || duplicateUprnCount > 0; + const nextStatus = needsReview ? "needs_review" : "complete"; + + await db + .update(bulkAddressUploads) + .set({ status: nextStatus }) + .where(eq(bulkAddressUploads.id, uploadId)); + + revalidatePath("/portfolio/[slug]", "layout"); + + return NextResponse.json( + { inserted, missingUprnCount, duplicateUprnCount, status: nextStatus }, + { status: 200 } + ); + } catch (err) { + console.error("Failed to finalize bulk upload:", err); + const detail = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { error: "Failed to import properties", detail }, + { status: 500 } + ); + } +} diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 132721b..da360a9 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -385,6 +385,7 @@ export interface PropertyWithRelations extends Record { totalFloorArea: number | null; co2Emissions: number | null; mainfuel: string | null; + lexiscore: number | null; } export type NonIntrusiveSurveyNotes = InferModel< diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index 2eae693..91c81ef 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -28,6 +28,7 @@ interface Props { const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]); const FAILED_STATUSES = new Set(["failed", "failure", "error"]); +const FINAL_UPLOAD_STATUSES = new Set(["complete", "needs_review"]); export default function OnboardingProgress({ taskId, @@ -40,9 +41,37 @@ export default function OnboardingProgress({ const [data, setData] = useState(null); const [uploadStatus, setUploadStatus] = useState(null); const [fetchError, setFetchError] = useState(false); + const [finalizeError, setFinalizeError] = useState(null); const intervalRef = useRef | null>(null); const combineFiredRef = useRef(false); - const redirectedRef = useRef(false); + const finalizeFiredRef = useRef(false); + const refreshedRef = useRef(false); + + async function fireFinalize() { + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/finalize`, + { method: "POST" } + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const msg = + body?.detail || body?.error || `Finalize failed (${res.status})`; + setFinalizeError(msg); + finalizeFiredRef.current = false; + } + } catch (err) { + console.error("Failed to trigger finalize:", err); + setFinalizeError(err instanceof Error ? err.message : "Network error"); + finalizeFiredRef.current = false; + } + } + + function retryFinalize() { + setFinalizeError(null); + finalizeFiredRef.current = true; + fireFinalize(); + } useEffect(() => { async function poll() { @@ -67,12 +96,16 @@ export default function OnboardingProgress({ if (uploadRes.ok) { const upload: UploadStatus = await uploadRes.json(); setUploadStatus(upload); - if (upload.status === "awaiting_review" && !redirectedRef.current) { - redirectedRef.current = true; + + if (upload.status === "awaiting_review" && !finalizeFiredRef.current) { + finalizeFiredRef.current = true; + fireFinalize(); + } + + if (FINAL_UPLOAD_STATUSES.has(upload.status) && !refreshedRef.current) { + refreshedRef.current = true; if (intervalRef.current) clearInterval(intervalRef.current); - router.push( - `/portfolio/${portfolioSlug}/bulk-upload/${uploadId}/confirm-matches` - ); + router.refresh(); return; } } @@ -85,6 +118,7 @@ export default function OnboardingProgress({ poll(); intervalRef.current = setInterval(poll, 3000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId, portfolioId, portfolioSlug, uploadId, router]); if (fetchError) return null; @@ -105,7 +139,7 @@ export default function OnboardingProgress({ const isFailed = FAILED_STATUSES.has(data.status.toLowerCase()); const isCombining = taskDone && !isFailed && uploadStatus?.status === "combining"; - const isAwaitingReview = + const isImporting = taskDone && !isFailed && uploadStatus?.status === "awaiting_review"; return ( @@ -141,14 +175,30 @@ export default function OnboardingProgress({ Combining results… )} - {isAwaitingReview && ( - - - Ready for review + {isImporting && ( + + + Importing to portfolio… )}
+ {finalizeError && ( +
+
+

Import failed

+

{finalizeError}

+
+ +
+ )} + {isDomnaUser && ( r.flags.includes(filter)); - - const basePath = `/portfolio/${slug}/bulk-upload/${uploadId}/confirm-matches`; - const pageStart = data.total === 0 ? 0 : offset + 1; - const pageEnd = Math.min(offset + data.rows.length, data.total); - const hasPrev = offset > 0; - const hasNext = offset + limit < data.total; - const prevOffset = Math.max(0, offset - limit); - const nextOffset = offset + limit; - - return ( -
-
- - All ({data.total}) - - - Missing ({data.flags_summary.missing}) - - - Duplicates ({data.flags_summary.duplicates}) - -
- -
- - - - - - - - - - - - - - {rows.length === 0 && ( - - - - )} - {rows.map((row) => ( - - - - - - - - - - ))} - -
Internal RefInput AddressUPRNMatched AddressScoreFlagsActions
- No rows match this filter. -
{row.internal_reference ?? "—"}{row.input_address || "—"}{row.uprn ?? "—"}{row.matched_address ?? "—"} - - {scoreChipLabel(row.score_bucket)} - - -
- {row.flags.map((f) => ( - - {f === "missing" ? "Missing" : "Duplicate"} - - ))} - {row.flags.length === 0 && } -
-
- -
-
- -
- - Showing {pageStart}–{pageEnd} of {data.total} - -
- {hasPrev ? ( - - Prev - - ) : ( - - Prev - - )} - {hasNext ? ( - - Next - - ) : ( - - Next - - )} -
-
-
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx deleted file mode 100644 index a12826a..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use server"; - -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { redirect, notFound } from "next/navigation"; -import { cookies, headers } from "next/headers"; -import Link from "next/link"; -import { ArrowLeftIcon } from "@heroicons/react/24/outline"; -import ConfirmMatchesClient, { - CombinedResultsResponse, -} from "./ConfirmMatchesClient"; - -const DEFAULT_LIMIT = 100; - -export default async function ConfirmMatchesPage(props: { - params: Promise<{ slug: string; uploadId: string }>; - searchParams: Promise<{ offset?: string; limit?: string; filter?: string }>; -}) { - const { slug, uploadId } = await props.params; - const search = await props.searchParams; - const session = await getServerSession(AuthOptions); - if (!session) redirect("/login"); - - const [upload] = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) notFound(); - if (upload.status !== "awaiting_review") { - redirect(`/portfolio/${slug}/bulk-upload/${uploadId}`); - } - - const offset = Math.max(0, parseInt(search.offset ?? "0", 10) || 0); - const limit = Math.max(1, Math.min(500, parseInt(search.limit ?? `${DEFAULT_LIMIT}`, 10) || DEFAULT_LIMIT)); - const filter = search.filter === "missing" || search.filter === "duplicate" ? search.filter : "all"; - - const h = await headers(); - const host = h.get("host"); - const proto = h.get("x-forwarded-proto") ?? "http"; - const cookieStore = await cookies(); - const cookieHeader = cookieStore.getAll().map((c) => `${c.name}=${c.value}`).join("; "); - - const url = `${proto}://${host}/api/portfolio/${upload.portfolioId}/bulk-uploads/${uploadId}/combined-results?offset=${offset}&limit=${limit}`; - - let data: CombinedResultsResponse | null = null; - let fetchError: string | null = null; - try { - const res = await fetch(url, { headers: { Cookie: cookieHeader }, cache: "no-store" }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - const upstreamStatus = body?.upstreamStatus; - const upstreamBody = body?.upstreamBody; - fetchError = `Failed to load results (${res.status})${upstreamStatus ? ` · upstream ${upstreamStatus}` : ""}${upstreamBody ? ` · ${upstreamBody}` : ""}`; - console.error("Confirm-matches fetch error:", { status: res.status, body }); - } else { - data = (await res.json()) as CombinedResultsResponse; - } - } catch (err) { - console.error("Failed to fetch combined-results:", err); - fetchError = `Failed to load results · ${err instanceof Error ? err.message : String(err)}`; - } - - return ( -
- - - Back to upload - - -
-

- Review matches -

-

- {upload.filename} -

- {data && ( -

- {data.total} addresses ·{" "} - {data.flags_summary.duplicates} duplicates ·{" "} - {data.flags_summary.missing} missing ·{" "} - {data.flags_summary.matched} matched -

- )} -
- - {fetchError && ( -
- {fetchError} -
- )} - - {data && ( - - )} -
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx index 438b9ad..fb96edd 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx @@ -62,11 +62,11 @@ const STATUS_CONFIG = { cta: false, }, awaiting_review: { - icon: CheckCircleIcon, - iconBg: "bg-green-50", - iconColor: "text-green-500", - title: "Ready for review", - body: "Your matches are ready. Review and confirm before finalising onboarding.", + icon: ArrowPathIcon, + iconBg: "bg-blue-50", + iconColor: "text-blue-500", + title: "Importing addresses…", + body: "Matches ready, writing into your portfolio.", cta: false, }, complete: { @@ -77,6 +77,14 @@ const STATUS_CONFIG = { body: "All addresses have been imported into your portfolio.", cta: false, }, + needs_review: { + icon: ExclamationCircleIcon, + iconBg: "bg-amber-50", + iconColor: "text-amber-500", + title: "Imported with issues", + body: "Some addresses didn't match a UPRN or matched the same UPRN as another row. Open the properties list to fix them manually.", + cta: false, + }, failed: { icon: ExclamationCircleIcon, iconBg: "bg-red-50", @@ -181,14 +189,14 @@ export default async function BulkUploadDetailPage(props: { /> )} - {statusKey === "awaiting_review" && ( - - Review matches + Open properties - + )} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 5e34583..239a09d 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -314,7 +314,7 @@ export default function PropertyTable({ () => { const init: VisibilityState = {}; OPTIONAL_COLUMN_IDS.forEach((id) => { - init[id] = false; + init[id] = id === "lexiscore"; }); return init; }, diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 7ef022e..11595e4 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -158,6 +158,7 @@ export const OPTIONAL_COLUMN_IDS = [ "totalFloorArea", "co2Emissions", "mainfuel", + "lexiscore", ] as const; export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number]; @@ -170,6 +171,7 @@ const OPTIONAL_COLUMN_LABELS: Record = { totalFloorArea: "Floor Area (m²)", co2Emissions: "CO₂ Emissions", mainfuel: "Main Fuel", + lexiscore: "Match confidence", }; export { OPTIONAL_COLUMN_LABELS }; @@ -455,6 +457,23 @@ const optionalColumns: ColumnDef[] = [ return label ? {label} : ; }, }, + { + id: "lexiscore", + accessorKey: "lexiscore", + header: () =>
Match
, + cell: ({ row }) => { + const score = row.original.lexiscore; + if (score == null) return ; + const bucket = score >= 0.85 ? "High" : score >= 0.65 ? "Medium" : "Low"; + const cls = + bucket === "High" + ? "bg-green-50 text-green-700" + : bucket === "Medium" + ? "bg-amber-50 text-amber-700" + : "bg-red-50 text-red-700"; + return {bucket}; + }, + }, ]; export const columns: ColumnDef[] = [ diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 8d7ff89..e3676c8 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -710,7 +710,8 @@ export async function getProperties( epc.is_expired AS "epcIsExpired", epc.total_floor_area AS "totalFloorArea", epc.co2_emissions AS "co2Emissions", - epc.mainfuel AS mainfuel + epc.mainfuel AS mainfuel, + p.lexiscore AS lexiscore FROM property p LEFT JOIN property_targets t ON t.property_id = p.id @@ -751,7 +752,8 @@ export async function getProperties( epc.is_expired, epc.total_floor_area, epc.co2_emissions, - epc.mainfuel + epc.mainfuel, + p.lexiscore LIMIT ${limit} OFFSET ${offset}; `); From a37fd032021bffbc20d1271d4f1505c41d9427ce Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 27 Apr 2026 11:59:53 +0000 Subject: [PATCH 08/22] added domna tech password --- src/app/components/portfolio/AddNew.tsx | 12 +++++++++++- .../[slug]/components/PropertyTable.tsx | 2 +- .../components/propertyTableColumns.tsx | 19 ------------------- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/app/components/portfolio/AddNew.tsx b/src/app/components/portfolio/AddNew.tsx index e6f91be..cef42cb 100644 --- a/src/app/components/portfolio/AddNew.tsx +++ b/src/app/components/portfolio/AddNew.tsx @@ -35,6 +35,16 @@ export default function AddNew({ router.push(`/portfolio/${portfolioId}/remote-assessment`); } + function handleBulkUploadClick() { + const pw = window.prompt("Enter password to access bulk upload"); + if (pw === null) return; + if (pw === "domnatechteamonly") { + setIsBulkUploadOpen(true); + } else { + window.alert("Incorrect password"); + } + } + return ( <> {({ active }) => ( - {error &&

{error}

} + {error && ( +

+ {error instanceof Error ? error.message : "Something went wrong"} +

+ )} ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx index cc5a949..281f574 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx @@ -1,5 +1,3 @@ -"use server"; - import { db } from "@/app/db/db"; import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; import { eq, desc } from "drizzle-orm"; From b8d83747abdb6b97e29b3983138e522fe391c920 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 6 May 2026 09:38:46 +0000 Subject: [PATCH 15/22] save local --- .devcontainer/Dockerfile | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index edca767..96e44d9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -8,7 +8,7 @@ ARG DEBIAN_FRONTEND=noninteractive # Install system dependencies in a single layer RUN apt update && apt install -y --no-install-recommends \ - sudo jq vim curl bash-completion \ + sudo jq vim curl bash-completion iputils-ping \ && apt autoremove -y \ && rm -rf /var/lib/apt/lists/* @@ -57,12 +57,7 @@ RUN git clone https://github.com/LazyVim/starter /home/${USER}/.config/nvim \ RUN curl -fsSL https://claude.ai/install.sh | bash \ && export PATH="/home/${USER}/.local/bin:${PATH}" \ && claude plugin marketplace add JuliusBrussee/caveman \ - && claude plugin install caveman@caveman \ - && npx skills@latest add --global --yes mattpocock/skills/grill-me \ - && npx skills@latest add --global --yes mattpocock/skills/to-prd \ - && npx skills@latest add --global --yes mattpocock/skills/ubiquitous-language \ - && npx skills@latest add --global --yes mattpocock/skills/tdd \ - && npx skills@latest add --global --yes mattpocock/skills/improve-codebase-architecture + && claude plugin install caveman@caveman ENV PATH="/home/vscode/.local/bin:${PATH}" USER root From 8170601efb1457ccdfbf9eb3b75b0aa9704ead62 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 6 May 2026 15:49:34 +0000 Subject: [PATCH 16/22] refactored with improve-code-archievture and grill-with-docs --- .devcontainer/Dockerfile | 5 - CLAUDE.md | 6 +- CONTEXT.md | 74 +++++ docs/adr/0001-bulk-upload-state-machine.md | 20 ++ ...ulk-upload-browser-driven-orchestration.md | 17 ++ .../0003-task-creation-inside-bulk-upload.md | 12 + ...0004-bulk-upload-explicit-stage-buttons.md | 19 ++ .../bulk-uploads/[uploadId]/combine/route.ts | 83 ++---- .../bulk-uploads/[uploadId]/finalize/route.ts | 54 ++-- .../bulk-uploads/[uploadId]/progress/route.ts | 17 ++ .../bulk-uploads/[uploadId]/route.ts | 66 +---- .../start-address-matching/route.ts | 119 ++------ .../[portfolioId]/bulk-uploads/route.ts | 13 +- src/app/api/tasks/route.ts | 54 ---- .../upload/bulk-addresses/confirm/route.ts | 6 +- src/app/api/upload/bulk-addresses/route.ts | 4 +- .../portfolio/BulkUploadComingSoonModal.tsx | 95 ++---- .../[uploadId]/OnboardingProgress.tsx | 272 ++++++----------- .../[uploadId]/StartAddressMatchingButton.tsx | 49 +--- .../map-columns/MapColumnsClient.tsx | 37 +-- .../bulk-upload/[uploadId]/page.tsx | 1 - src/app/utils/s3.ts | 14 + src/lib/bulkUpload/client.ts | 173 +++++++++++ src/lib/bulkUpload/keys.ts | 4 + src/lib/bulkUpload/server.ts | 277 ++++++++++++++++++ src/lib/bulkUpload/types.ts | 44 +++ src/lib/session.ts | 8 + 27 files changed, 924 insertions(+), 619 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-bulk-upload-state-machine.md create mode 100644 docs/adr/0002-bulk-upload-browser-driven-orchestration.md create mode 100644 docs/adr/0003-task-creation-inside-bulk-upload.md create mode 100644 docs/adr/0004-bulk-upload-explicit-stage-buttons.md create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/progress/route.ts create mode 100644 src/lib/bulkUpload/client.ts create mode 100644 src/lib/bulkUpload/keys.ts create mode 100644 src/lib/bulkUpload/server.ts create mode 100644 src/lib/bulkUpload/types.ts create mode 100644 src/lib/session.ts diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f193fda..997ede6 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,13 +7,8 @@ ARG DEBIAN_FRONTEND=noninteractive # Base CLI tooling (sudo, git, ripgrep/fd for editors, etc.). RUN apt update && apt install -y --no-install-recommends \ -<<<<<<< HEAD - sudo jq vim curl bash-completion iputils-ping \ - && apt autoremove -y \ -======= sudo jq vim curl bash-completion \ ripgrep fd-find git make unzip \ ->>>>>>> main && rm -rf /var/lib/apt/lists/* # Passwordless-sudo dev user (UID/GID injected from the host via compose). diff --git a/CLAUDE.md b/CLAUDE.md index 6cbbf3f..e35dd11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,10 @@ ## React -- **Avoid `useEffect` and `useMemo`.** Derive values inline, use Server Components + Route Handlers, event handlers, or `useSyncExternalStore` instead. If a hook is genuinely the only option, flag it and ask before using it. +- **Avoid `useEffect` and `useMemo`.** Derive values inline, use Server Components + Route Handlers, event handlers. If a hook is genuinely the only option, flag it and ask before using it. + +- Instead of raw fetch use reactQuery to allow handling of mutations ## Next.js 15 route handlers - `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring. - - diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..652d44c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,74 @@ +# Context + +This document captures the domain language used in this project. Terms here are the **canonical** ones — when more than one word exists for a concept, we pick one and treat the others as aliases to avoid. + +This file grows as terms are resolved during design conversations. Concepts that haven't been examined yet are not listed. + +## Language + +### Bulk upload + +**BulkUpload**: +A user-supplied spreadsheet of addresses for a Portfolio, transformed and matched to UPRNs before being inserted as Properties. Has an explicit lifecycle from upload through finalisation. +_Avoid_: import, batch, file upload, ingest + +**ColumnMapping**: +The user's declaration of which spreadsheet column means what (e.g. column "Property Address" means `address_1`). Stored as JSON on the BulkUpload row. +_Avoid_: schema, header map, field mapping + +**UPRN**: +Unique Property Reference Number — the UK national identifier for an address. Address matching attaches a UPRN to each row where possible. + +**Address matching**: +The pipeline stage that splits the source file by postcode, looks up UPRNs, and produces matched-address output. Triggered via FastAPI. +_Avoid_: postcode lookup, address resolution, address lookup + +**Combiner**: +The pipeline stage that aggregates the per-postcode address-matching outputs into a single combined CSV in S3, ready for review. +_Avoid_: aggregator, merger + +**Finalise**: +The terminal action that reads the combiner output, inserts rows as Properties on the Portfolio, and decides whether the BulkUpload needs further review. +_Avoid_: import, commit, ingest + +## Lifecycle + +A **BulkUpload** moves through these statuses: + +``` +ready_for_processing + → mapping_complete (user submits ColumnMapping; Next.js writes) + → processing (Address matching triggered; Next.js writes) + → combining (Combiner stage running; FastAPI writes directly) + → awaiting_review (Combiner output in S3; FastAPI writes directly) + → complete (Finalise succeeded, no row issues; Next.js writes) + → needs_review (Finalise succeeded, missing or duplicate UPRNs; Next.js writes) + → failed (FastAPI reports in-flight failure — schema only, not yet wired) +``` + +`complete`, `needs_review`, and `failed` are terminal. + +Re-mapping (PATCHing `columnMapping`) is legal only in `ready_for_processing` and `mapping_complete`. Any later state rejects with 409. + +**Two writers**: Next.js owns transitions out of `mapping_complete`, into `processing`, and the terminal Finalise outcomes. FastAPI owns `combining` and `awaiting_review` — writing them direct to the DB during the combiner run. The BulkUpload aggregate observes both. + +See [ADR-0001](./docs/adr/0001-bulk-upload-state-machine.md) for the deliberate "not yet" decisions baked into this lifecycle. + +## Relationships + +- A **Portfolio** has many **BulkUploads**. +- A **BulkUpload** produces zero or more **Properties** when finalised. +- A **BulkUpload** has at most one **Task** (the orchestration handle for the FastAPI pipeline run); a Task has many **SubTasks** (one per pipeline stage: address matching, combiner). + +## Example dialogue + +> **Dev:** "If the **Combiner** finishes but the user hasn't clicked Finalise, what does the user see?" +> **Domain expert:** "The BulkUpload sits in `awaiting_review`. The frontend polls and shows a 'review and confirm' button. Nothing's been written to **Properties** yet." +> +> **Dev:** "And if **Finalise** runs and 30% of rows have no **UPRN**?" +> **Domain expert:** "Those still get imported as **Properties** — just without a UPRN — and the BulkUpload moves to `needs_review`. The `complete` state is only for clean runs." + +## Flagged ambiguities + +- "Upload" is used in the codebase to mean both the file-on-S3 and the BulkUpload row. We standardise on **BulkUpload** for the row; the file is just "the source file." +- "Onboarding" appears in some route paths (`bulk_onboarding_inputs/...`) but isn't part of this glossary — we use **BulkUpload** end-to-end. diff --git a/docs/adr/0001-bulk-upload-state-machine.md b/docs/adr/0001-bulk-upload-state-machine.md new file mode 100644 index 0000000..741e4a3 --- /dev/null +++ b/docs/adr/0001-bulk-upload-state-machine.md @@ -0,0 +1,20 @@ +# BulkUpload state machine (v1) + +The BulkUpload lifecycle is: + +``` +ready_for_processing → mapping_complete → processing → combining → awaiting_review + → complete | needs_review | failed +``` + +Two writers: Next.js writes the user-driven transitions (`mapping_complete`, `processing`, `complete`, `needs_review`); the FastAPI `bulk_address2uprn_combiner` worker writes `combining` and `awaiting_review` directly to the DB during its run. Any aggregate enforcing the state machine on the Next.js side must treat those two as observed-not-owned. + +Three deliberate "not yet" decisions are baked into this version, each likely to be re-suggested without a record: + +1. **`needs_review` is terminal — no recovery flow.** A finalised upload with missing or duplicate UPRNs ends up here, and that's it. The imported Properties show up in the property table; a proper review/recovery UI is planned but out of scope. Without this note, every future architecture pass will surface "needs_review has no exit" as a bug. + +2. **Re-mapping is rejected after `mapping_complete`.** PATCHing `columnMapping` once Address matching has been triggered returns 409. We considered (B) reset-and-rerun (clear the combiner output, return to `mapping_complete`, force the user to re-trigger) but rejected it for now: it requires cleaning up abandoned Tasks/SubTasks and re-charging a FastAPI run. (A) is the smallest correct thing — the DB column never drifts from what produced the combined output. Revisit when re-run-from-review lands. + +3. **`failed` exists in the schema but is not yet written by any route.** Synchronous trigger failures (route can't reach FastAPI) are surfaced as React Query toasts on 5xx; the BulkUpload stays in its prior status and the user retries. In-flight failures (Combiner crashes silently) currently leave uploads stuck in `processing` — this is known-incomplete, waiting on FastAPI-side callback work to report failure. The status is in the schema now so the seam is ready when that work is scheduled. + +These are the only "no" decisions in v1; everything else (concurrency guards on stage triggers, persisting `failed`, etc.) is intended to be added. diff --git a/docs/adr/0002-bulk-upload-browser-driven-orchestration.md b/docs/adr/0002-bulk-upload-browser-driven-orchestration.md new file mode 100644 index 0000000..f27a5e0 --- /dev/null +++ b/docs/adr/0002-bulk-upload-browser-driven-orchestration.md @@ -0,0 +1,17 @@ +# BulkUpload pipeline stays browser-driven for now + +The BulkUpload pipeline chains three stages — address matching, combiner trigger, finalise — and the chain is currently driven by the **frontend polling** in `OnboardingProgress.tsx`. The browser fires `POST /combine` when the Task looks done, then `POST /finalize` when the upload reaches `awaiting_review`. Close the tab → upload gets stuck. + +A server-driven alternative (FastAPI callback into a Next.js webhook, or a sweeper route) would be more robust, but the entire bulk-upload flow is scheduled for redesign. This code path is being kept as **internal tooling for the Domna tech team** to onboard portfolios while the new flow is built — not for end-user use. + +So the cleanup invests only in things that survive the redesign: +- A real state machine on the BulkUpload aggregate (so the tech team can debug from the row alone). +- Deduplicated FastAPI trigger logic. +- Concurrency guards on stage triggers. + +It deliberately does **not** invest in: +- Server-side stage orchestration. +- Recovery from `failed` / stuck uploads. +- A real `awaiting_review` review UI. + +When the redesign lands, browser-driven chaining and the auto-finalise behaviour go with it. Future readers asking "why didn't they fix this" — that's why. diff --git a/docs/adr/0003-task-creation-inside-bulk-upload.md b/docs/adr/0003-task-creation-inside-bulk-upload.md new file mode 100644 index 0000000..2915346 --- /dev/null +++ b/docs/adr/0003-task-creation-inside-bulk-upload.md @@ -0,0 +1,12 @@ +# Task creation lives in BulkUpload, not behind a generic Task endpoint + +A `Task` is the orchestration handle for a FastAPI pipeline run; per `CONTEXT.md`, **a BulkUpload has at most one Task**. Today BulkUpload is the only feature that creates Tasks. + +We're collapsing the previous two-step client seam — `POST /api/tasks` to create a Task and SubTask, then `POST /bulk-uploads/[uploadId]/start-address-matching` with the resulting IDs — into a single route. Task + SubTask creation moves inside `triggerAddressMatching` in `src/lib/bulkUpload/server.ts`. The generic `POST /api/tasks` endpoint is deleted; the `GET /api/tasks` listing (admin Tasks UI) stays. + +The trade-off was between: + +- **(rejected) Keep `POST /api/tasks` for future generic use.** No other feature creates Tasks today. By the deletion test, the generic endpoint wasn't earning its keep — every line was overhead carried for hypothetical callers, while the only real caller had to perform a leaky two-call dance to satisfy a contract that didn't reflect the domain. +- **(chosen) Collapse Task creation into the BulkUpload module.** The seam matches the domain: BulkUpload owns its Task's lifecycle, including the moment of creation. One route handles the full transition into `processing`. The client surface (`useStartAddressMatching`) is one mutation, not a chain. + +A future reader will see `/api/tasks` exposing GET-only and reasonably wonder where Tasks are created. The answer is: inside whichever feature owns the Task. Today that's BulkUpload only. If a second feature ever needs to create Tasks, re-extract a generic creator at that point — extracting from two real consumers is straightforward, whereas keeping a speculative endpoint live without consumers leaves a stale-by-default seam that drifts from how Tasks are actually created. diff --git a/docs/adr/0004-bulk-upload-explicit-stage-buttons.md b/docs/adr/0004-bulk-upload-explicit-stage-buttons.md new file mode 100644 index 0000000..de7a98c --- /dev/null +++ b/docs/adr/0004-bulk-upload-explicit-stage-buttons.md @@ -0,0 +1,19 @@ +# Bulk-upload pipeline advances via explicit Run Combiner / Finalise buttons + +[ADR-0002](./0002-bulk-upload-browser-driven-orchestration.md) described the bulk-upload pipeline as **browser-driven and auto-firing**: a polling loop in `OnboardingProgress.tsx` watched the Task summary and fired `POST /combine` when the Task looked done, then `POST /finalize` when the upload reached `awaiting_review`, with mutable `combineFired` / `finalizeFired` / `refreshed` flags gating the side effects. + +This decision keeps the pipeline browser-driven (per ADR-0002) but replaces the auto-fire behaviour with **explicit buttons** the Domna tech team clicks to advance each stage. The state machine in [ADR-0001](./0001-bulk-upload-state-machine.md) is unchanged. + +Concretely: + +- The progress screen polls a single `GET /bulk-uploads/[uploadId]/progress` snapshot (Task summary + BulkUpload row in one query). +- "Run Combiner" appears when the Task reaches terminal-non-failed and the Combiner hasn't been triggered yet. +- "Finalise" appears when the BulkUpload reaches `awaiting_review`. +- Polling stops once the BulkUpload reaches a terminal status; on `useFinalize` success the caller runs `router.refresh()`. + +The trade-off was between: + +- **(rejected) Keep auto-fire and translate the orchestration to react-query with `useEffect`s.** Functional, but the auto-fire UX hides where the pipeline gets stuck — which is the failure mode the Domna tech team needs to debug. CLAUDE.md's "avoid `useEffect`" rule also gets stretched: three effects, one per side-effecting transition, each watching derived eligibility flags. +- **(chosen) Explicit buttons.** The "fire once" mutable flags collapse into react-query mutation state (`isIdle` / `isPending` / `isSuccess`) — pure derivations from the cache, no `useEffect`. Each stage is observable, retryable, and reflects the current BulkUpload status directly. Tab close still leaves uploads stuck (browser-driven progression, per ADR-0002), but "stuck" is now legible: the team sees which button is pending. + +When the bulk-upload redesign lands (per ADR-0002, "out of scope"), this entire screen and its buttons go away in favour of server-driven progression. Until then, the buttons are the right level of investment: they survive the redesign as discoverable artifacts of the prior flow, and they make the current flow tractable for the team using it. diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts index 673e2c5..afee7f0 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts @@ -1,7 +1,5 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { subTasks } from "@/app/db/schema/tasks/subtask"; -import { eq } from "drizzle-orm"; +import { requestCombineRetrigger } from "@/lib/bulkUpload/server"; +import { readSessionToken } from "@/lib/session"; import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; @@ -14,65 +12,24 @@ export async function POST( if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { uploadId } = await params; + const result = await requestCombineRetrigger({ + uploadId, + sessionToken: readSessionToken(request), + }); - const [upload] = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (!upload.taskId) - return NextResponse.json({ error: "Upload has no task" }, { status: 422 }); - if (upload.combinedOutputS3Uri) - return NextResponse.json({ alreadyCombined: true }, { status: 200 }); - - const fastapiUrl = process.env.FASTAPI_API_URL; - const fastapiKey = process.env.FASTAPI_API_KEY; - if (!fastapiUrl || !fastapiKey) { - console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); - return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); + switch (result.kind) { + case "triggered": + return NextResponse.json( + { taskId: result.taskId, subTaskId: result.subTaskId }, + { status: 200 } + ); + case "already_combined": + return NextResponse.json({ alreadyCombined: true }, { status: 200 }); + case "not_found": + return NextResponse.json({ error: "Not found" }, { status: 404 }); + case "missing_task": + return NextResponse.json({ error: "Upload has no task" }, { status: 422 }); + case "trigger_failed": + return NextResponse.json({ error: result.message }, { status: result.status }); } - - const [subTask] = await db - .insert(subTasks) - .values({ - taskId: upload.taskId, - status: "waiting", - }) - .returning(); - - const messageBody = { task_id: upload.taskId, sub_task_id: subTask.id }; - - const sessionToken = - request.cookies.get("__Secure-next-auth.session-token")?.value ?? - request.cookies.get("next-auth.session-token")?.value; - - try { - const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-combiner`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": fastapiKey, - Authorization: `Bearer ${sessionToken}`, - }, - body: JSON.stringify(messageBody), - }); - - if (!triggerRes.ok) { - const errText = await triggerRes.text().catch(() => ""); - console.error("Backend trigger-combiner failed:", triggerRes.status, errText); - return NextResponse.json({ error: "Failed to trigger combiner" }, { status: 502 }); - } - } catch (err) { - console.error("Failed to reach backend trigger-combiner:", err); - return NextResponse.json({ error: "Failed to trigger combiner" }, { status: 502 }); - } - - await db - .update(subTasks) - .set({ inputs: JSON.stringify(messageBody) }) - .where(eq(subTasks.id, subTask.id)); - - return NextResponse.json({ taskId: upload.taskId, subTaskId: subTask.id }, { status: 200 }); } diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts index 98fb5d1..b006e92 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts @@ -1,13 +1,13 @@ import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; import { property } from "@/app/db/schema/property"; -import { eq, sql } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import S3 from "aws-sdk/clients/s3"; +import { createRetrofitDataS3Client } from "@/app/utils/s3"; import * as XLSX from "xlsx"; +import { loadForFinalize, markFinalized } from "@/lib/bulkUpload/server"; const ADDRESS_COLS = ["Address 1", "Address 2", "Address 3"] as const; const POSTCODE_COL = "postcode"; @@ -69,33 +69,31 @@ export async function POST( const { uploadId } = await params; - const [upload] = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); + const guarded = await loadForFinalize(uploadId); + switch (guarded.kind) { + case "not_found": + return NextResponse.json({ error: "Not found" }, { status: 404 }); + case "already_finalized": + return NextResponse.json( + { alreadyComplete: true, status: guarded.status }, + { status: 200 } + ); + case "wrong_state": + return NextResponse.json( + { error: `Upload not ready to finalize (state: ${guarded.current})` }, + { status: 409 } + ); + case "not_yet_combined": + return NextResponse.json({ error: "Combiner not finished" }, { status: 409 }); + } + const upload = guarded.upload; - if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (upload.status === "complete" || upload.status === "needs_review") { - return NextResponse.json({ alreadyComplete: true, status: upload.status }, { status: 200 }); - } - if (upload.status !== "awaiting_review") { - return NextResponse.json({ error: "Upload not ready to finalize" }, { status: 422 }); - } - if (!upload.combinedOutputS3Uri) { - return NextResponse.json({ error: "Combiner not finished" }, { status: 409 }); - } - - const parsed = parseS3Uri(upload.combinedOutputS3Uri); + const parsed = parseS3Uri(upload.combinedOutputS3Uri!); if (!parsed) { return NextResponse.json({ error: "Invalid combined output S3 URI" }, { status: 500 }); } - const s3 = new S3({ - region: process.env.RETROFIT_DATA_DEV_REGION, - accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, - secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, - }); + const s3 = createRetrofitDataS3Client(); let rawRows: Record[]; try { @@ -169,13 +167,9 @@ export async function POST( } const needsReview = missingUprnCount > 0 || duplicateUprnCount > 0; + await markFinalized(uploadId, { needsReview }); const nextStatus = needsReview ? "needs_review" : "complete"; - await db - .update(bulkAddressUploads) - .set({ status: nextStatus }) - .where(eq(bulkAddressUploads.id, uploadId)); - revalidatePath("/portfolio/[slug]", "layout"); return NextResponse.json( diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/progress/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/progress/route.ts new file mode 100644 index 0000000..f5ac996 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/progress/route.ts @@ -0,0 +1,17 @@ +import { getProgressView } from "@/lib/bulkUpload/server"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + const view = await getProgressView(uploadId); + if (!view) return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json(view, { status: 200 }); +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts index f51bd73..2b45299 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts @@ -1,38 +1,11 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; +import { setColumnMapping } from "@/lib/bulkUpload/server"; import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { z } from "zod"; const PatchSchema = z.object({ columnMapping: z.record(z.string(), z.string()), }); -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } -) { - const session = await getServerSession(AuthOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const { uploadId } = await params; - - const [upload] = await db - .select({ - status: bulkAddressUploads.status, - combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri, - }) - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - - return NextResponse.json(upload, { status: 200 }); -} - export async function PATCH( request: NextRequest, { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } @@ -43,33 +16,26 @@ export async function PATCH( try { body = PatchSchema.parse(await request.json()); } catch { - return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); - } - - const values = Object.values(body.columnMapping); - const hasAddress = values.includes("address_1"); - const hasPostcode = values.includes("postcode"); - if (!hasAddress || !hasPostcode) { - return NextResponse.json( - { msg: "Mapping must include address_1 and postcode." }, - { status: 422 } - ); + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); } try { - const [updated] = await db - .update(bulkAddressUploads) - .set({ columnMapping: body.columnMapping, status: "mapping_complete" }) - .where(eq(bulkAddressUploads.id, uploadId)) - .returning(); - - if (!updated) { - return NextResponse.json({ msg: "Not found" }, { status: 404 }); + const result = await setColumnMapping(uploadId, body.columnMapping); + switch (result.kind) { + case "ok": + return NextResponse.json(result.upload, { status: 200 }); + case "not_found": + return NextResponse.json({ error: "Not found" }, { status: 404 }); + case "invalid_status": + return NextResponse.json( + { error: `Cannot remap upload in state '${result.current}'` }, + { status: 409 } + ); + case "invalid_mapping": + return NextResponse.json({ error: result.reason }, { status: 422 }); } - - return NextResponse.json(updated, { status: 200 }); } catch (error) { console.error("Failed to save column mapping:", error); - return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/start-address-matching/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/start-address-matching/route.ts index e5b77b2..5fd282f 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/start-address-matching/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/start-address-matching/route.ts @@ -1,15 +1,10 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { tasks } from "@/app/db/schema/tasks/tasks"; -import { subTasks } from "@/app/db/schema/tasks/subtask"; -import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { z } from "zod"; -import { createS3Client } from "@/app/utils/s3"; -import S3 from "aws-sdk/clients/s3"; +import { createS3Client, createRetrofitDataS3Client, retrofitDataS3Bucket } from "@/app/utils/s3"; import * as XLSX from "xlsx"; +import { loadForAddressMatching, triggerAddressMatching } from "@/lib/bulkUpload/server"; +import { readSessionToken } from "@/lib/session"; const FIELD_RENAME: Record = { address_1: "Address 1", @@ -19,11 +14,6 @@ const FIELD_RENAME: Record = { internal_reference: "Internal Reference", }; -const BodySchema = z.object({ - taskId: z.string().uuid(), - subTaskId: z.string().uuid(), -}); - function transformFile( buffer: Buffer, columnMapping: Record @@ -72,38 +62,28 @@ export async function POST( const { portfolioId, uploadId } = await params; - let body; - try { - body = BodySchema.parse(await request.json()); - } catch { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + const guarded = await loadForAddressMatching(uploadId); + switch (guarded.kind) { + case "not_found": + return NextResponse.json({ error: "Not found" }, { status: 404 }); + case "wrong_state": + return NextResponse.json( + { error: `Upload not ready for onboarding (state: ${guarded.current})` }, + { status: 409 } + ); + case "missing_mapping": + return NextResponse.json({ error: "Column mapping missing" }, { status: 422 }); } - - const [upload] = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (upload.status !== "mapping_complete") - return NextResponse.json({ error: "Upload not ready for onboarding" }, { status: 422 }); - if (!upload.columnMapping) - return NextResponse.json({ error: "Column mapping missing" }, { status: 422 }); + const upload = guarded.upload; const s3 = createS3Client(); - const outputS3 = new S3({ - region: process.env.RETROFIT_DATA_DEV_REGION, - accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, - secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, - }); - const outputBucket = process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!; - const bucket = upload.s3Bucket; + const outputS3 = createRetrofitDataS3Client(); + const outputBucket = retrofitDataS3Bucket(); let fileBuffer: Buffer; try { const obj = await s3 - .getObject({ Bucket: bucket, Key: upload.s3Key }) + .getObject({ Bucket: upload.s3Bucket, Key: upload.s3Key }) .promise(); fileBuffer = Buffer.from(obj.Body as Uint8Array); } catch (err) { @@ -111,8 +91,9 @@ export async function POST( return NextResponse.json({ error: "Failed to read source file" }, { status: 500 }); } - const result = transformFile(fileBuffer, upload.columnMapping); - if (result.error) return NextResponse.json({ error: result.error }, { status: 422 }); + const transformed = transformFile(fileBuffer, upload.columnMapping!); + if (transformed.error) + return NextResponse.json({ error: transformed.error }, { status: 422 }); const transformedKey = `bulk_onboarding_inputs/${portfolioId}/${uploadId}.csv`; try { @@ -120,7 +101,7 @@ export async function POST( .putObject({ Bucket: outputBucket, Key: transformedKey, - Body: result.csv, + Body: transformed.csv, ContentType: "text/csv", }) .promise(); @@ -131,53 +112,13 @@ export async function POST( const s3Uri = `s3://${outputBucket}/${transformedKey}`; - const fastapiUrl = process.env.FASTAPI_API_URL; - const fastapiKey = process.env.FASTAPI_API_KEY; - if (!fastapiUrl || !fastapiKey) { - console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); - return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); - } + const trigger = await triggerAddressMatching({ + uploadId, + s3Uri, + sessionToken: readSessionToken(request), + }); + if (trigger.kind === "trigger_failed") + return NextResponse.json({ error: trigger.message }, { status: trigger.status }); - const sessionToken = - request.cookies.get("__Secure-next-auth.session-token")?.value ?? - request.cookies.get("next-auth.session-token")?.value; - - try { - const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-postcode-splitter`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": fastapiKey, - Authorization: `Bearer ${sessionToken}`, - }, - body: JSON.stringify({ - task_id: body.taskId, - sub_task_id: body.subTaskId, - s3_uri: s3Uri, - }), - }); - - if (!triggerRes.ok) { - const errText = await triggerRes.text().catch(() => ""); - console.error("Backend trigger-postcode-splitter failed:", triggerRes.status, errText); - return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 }); - } - } catch (err) { - console.error("Failed to reach backend trigger-postcode-splitter:", err); - return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 }); - } - - await Promise.all([ - db.update(bulkAddressUploads) - .set({ status: "processing", taskId: body.taskId }) - .where(eq(bulkAddressUploads.id, uploadId)), - db.update(tasks) - .set({ status: "in progress" }) - .where(eq(tasks.id, body.taskId)), - db.update(subTasks) - .set({ inputs: JSON.stringify({ task_id: body.taskId, sub_task_id: body.subTaskId, s3_uri: s3Uri }) }) - .where(eq(subTasks.id, body.subTaskId)), - ]); - - return NextResponse.json({ taskId: body.taskId }, { status: 200 }); + return NextResponse.json({ taskId: trigger.taskId }, { status: 200 }); } diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts index 86bd00f..a6ebbc1 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts @@ -1,6 +1,4 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq, desc } from "drizzle-orm"; +import { listForPortfolio } from "@/lib/bulkUpload/server"; import { NextRequest, NextResponse } from "next/server"; export async function GET( @@ -10,15 +8,10 @@ export async function GET( const { portfolioId } = await params; try { - const uploads = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.portfolioId, portfolioId)) - .orderBy(desc(bulkAddressUploads.createdAt)); - + const uploads = await listForPortfolio(portfolioId); return NextResponse.json(uploads, { status: 200 }); } catch (error) { console.error("Failed to fetch bulk uploads:", error); - return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index dc6e830..d11a239 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,61 +1,7 @@ import { db } from "@/app/db/db"; import { tasks } from "@/app/db/schema/tasks/tasks"; -import { subTasks } from "@/app/db/schema/tasks/subtask"; import { desc, count } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { z } from "zod"; - -const CreateTaskSchema = z.object({ - taskSource: z.string().min(1), - service: z.string().optional(), - source: z.literal("portfolio_id").optional(), - sourceId: z.string().optional(), - inputs: z.record(z.unknown()).optional(), -}); - -export async function POST(request: NextRequest) { - const session = await getServerSession(AuthOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - let body; - try { - body = CreateTaskSchema.parse(await request.json()); - } catch { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - try { - const now = new Date(); - - const [task] = await db - .insert(tasks) - .values({ - taskSource: body.taskSource, - service: body.service, - source: body.source, - sourceId: body.sourceId, - status: "waiting", - jobStarted: now, - }) - .returning(); - - const [subTask] = await db - .insert(subTasks) - .values({ - taskId: task.id, - status: "waiting", - inputs: body.inputs ? JSON.stringify(body.inputs) : null, - }) - .returning(); - - return NextResponse.json({ taskId: task.id, subTaskId: subTask.id }, { status: 201 }); - } catch (error) { - console.error("Failed to create task:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } -} export async function GET(request: NextRequest) { try { diff --git a/src/app/api/upload/bulk-addresses/confirm/route.ts b/src/app/api/upload/bulk-addresses/confirm/route.ts index 8edaab4..80e94e4 100644 --- a/src/app/api/upload/bulk-addresses/confirm/route.ts +++ b/src/app/api/upload/bulk-addresses/confirm/route.ts @@ -22,13 +22,13 @@ export async function POST(request: NextRequest) { body = BodySchema.parse(await request.json()); } catch (error) { console.error("Invalid input:", error); - return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); } const bucket = process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME; if (!bucket) { console.error("RETROFIT_PLAN_INPUT_BUCKET_NAME not set"); - return NextResponse.json({ msg: "Server misconfiguration" }, { status: 500 }); + return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); } try { @@ -50,6 +50,6 @@ export async function POST(request: NextRequest) { ); } catch (error) { console.error("Failed to record upload:", error); - return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/upload/bulk-addresses/route.ts b/src/app/api/upload/bulk-addresses/route.ts index 9eb1c41..a54966f 100644 --- a/src/app/api/upload/bulk-addresses/route.ts +++ b/src/app/api/upload/bulk-addresses/route.ts @@ -15,7 +15,7 @@ export async function POST(request: NextRequest) { body = BodySchema.parse(await request.json()); } catch (error) { console.error("Invalid input:", error); - return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); } try { @@ -31,6 +31,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ url: preSignedUrl }, { status: 200 }); } catch (error) { console.error(error); - return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx index a416f00..ab19c19 100644 --- a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx +++ b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx @@ -22,6 +22,7 @@ import { } from "@heroicons/react/24/outline"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; +import { useCreateBulkUpload } from "@/lib/bulkUpload/client"; const MAX_FILE_SIZE_MB = 50; const ALLOWED_EXTENSIONS = [".csv", ".xlsx", ".xls"]; @@ -99,22 +100,6 @@ async function validateHeaders(file: File): Promise<{ error: string | null; head return { error: null, headers }; } -async function getPresignedUrl( - userId: string, - portfolioId: string, - fileKey: string, - contentType: string -): Promise { - const res = await fetch("/api/upload/bulk-addresses", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, portfolioId, fileKey, contentType }), - }); - if (!res.ok) throw new Error("Failed to generate upload URL."); - const data = await res.json(); - return data.url; -} - export default function BulkUploadComingSoonModal({ isOpen, onClose, @@ -123,18 +108,22 @@ export default function BulkUploadComingSoonModal({ const session = useSession(); const router = useRouter(); const fileInputRef = useRef(null); + const createUpload = useCreateBulkUpload(); const [isDragging, setIsDragging] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [sourceHeaders, setSourceHeaders] = useState([]); const [validationError, setValidationError] = useState(null); const [validating, setValidating] = useState(false); - const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); - const [uploadError, setUploadError] = useState(null); + + const uploading = createUpload.isPending; + const uploadError = createUpload.error + ? "Upload failed. Please try again, or contact a Domna representative if the issue persists." + : null; async function handleFile(file: File) { - setUploadError(null); + createUpload.reset(); setSelectedFile(null); setValidationError(null); @@ -183,60 +172,38 @@ export default function BulkUploadComingSoonModal({ setSourceHeaders([]); setValidationError(null); setValidating(false); - setUploadError(null); - setUploading(false); setUploadProgress(null); + createUpload.reset(); onClose(); } - async function handleUpload() { + function handleUpload() { const userId = String(session.data?.user?.dbId ?? ""); if (!selectedFile || !userId) return; - setUploading(true); + const ext = getFileExtension(selectedFile.name); + const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const fileKey = generateS3Key(userId, portfolioId, ext); setUploadProgress(0); - setUploadError(null); - try { - const ext = getFileExtension(selectedFile.name); - const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; - const fileKey = generateS3Key(userId, portfolioId, ext); - - const presignedUrl = await getPresignedUrl(userId, portfolioId, fileKey, contentType); - - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("PUT", presignedUrl); - xhr.setRequestHeader("Content-Type", contentType); - xhr.upload.addEventListener("progress", (e) => { - if (e.lengthComputable) { - setUploadProgress(Math.round((e.loaded / e.total) * 100)); - } - }); - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) resolve(); - else reject(new Error(`S3 upload failed: ${xhr.status}`)); - }; - xhr.onerror = () => reject(new Error("Network error during upload")); - xhr.send(selectedFile); - }); - - const confirmRes = await fetch("/api/upload/bulk-addresses/confirm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileKey, filename: selectedFile.name, portfolioId, userId, sourceHeaders }), - }); - if (!confirmRes.ok) throw new Error("Failed to record upload."); - - const { id: uploadId } = await confirmRes.json(); - router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}/map-columns`); - onClose(); - } catch (err) { - setUploadError("Upload failed. Please try again, or contact a Domna representative if the issue persists."); - } finally { - setUploading(false); - setUploadProgress(null); - } + createUpload.mutate( + { + file: selectedFile, + portfolioId, + userId, + sourceHeaders, + contentType, + fileKey, + onProgress: setUploadProgress, + }, + { + onSuccess: ({ id: uploadId }) => { + router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}/map-columns`); + onClose(); + }, + onSettled: () => setUploadProgress(null), + } + ); } const canUpload = !!selectedFile && !uploading && !validating; diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index 68160eb..3824b1b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -1,172 +1,37 @@ "use client"; -import { useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; - -interface TaskData { - id: string; - taskSource: string; - status: string; - totalSubtasks: number; - completedSubtasks: number; - failedSubtasks: number; -} - -interface UploadStatus { - status: string; - combinedOutputS3Uri: string | null; -} +import { ArrowRightIcon } from "@heroicons/react/24/outline"; +import { + useBulkUploadProgress, + useFinalize, + useRequestCombine, +} from "@/lib/bulkUpload/client"; interface Props { - taskId: string; portfolioSlug: string; portfolioId: string; uploadId: string; isDomnaUser: boolean; } -interface Snapshot { - data: TaskData | null; - uploadStatus: UploadStatus | null; - fetchError: boolean; - finalizeError: string | null; -} - -const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]); -const FAILED_STATUSES = new Set(["failed", "failure", "error"]); -const FINAL_UPLOAD_STATUSES = new Set(["complete", "needs_review"]); - -function createProgressStore(args: { - taskId: string; - portfolioId: string; - uploadId: string; - onComplete: () => void; -}) { - let snapshot: Snapshot = { - data: null, - uploadStatus: null, - fetchError: false, - finalizeError: null, - }; - const listeners = new Set<() => void>(); - let intervalId: ReturnType | null = null; - let combineFired = false; - let finalizeFired = false; - let refreshed = false; - - function emit(patch: Partial) { - snapshot = { ...snapshot, ...patch }; - listeners.forEach((l) => l()); - } - - async function fireFinalize() { - try { - const res = await fetch( - `/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}/finalize`, - { method: "POST" } - ); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - const msg = - body?.detail || body?.error || `Finalize failed (${res.status})`; - emit({ finalizeError: msg }); - finalizeFired = false; - } - } catch (err) { - console.error("Failed to trigger finalize:", err); - emit({ finalizeError: err instanceof Error ? err.message : "Network error" }); - finalizeFired = false; - } - } - - async function poll() { - try { - const res = await fetch(`/api/tasks/${args.taskId}/summary`); - if (!res.ok) { emit({ fetchError: true }); return; } - const json: TaskData = await res.json(); - emit({ data: json }); - const status = json.status.toLowerCase(); - - if (TERMINAL_STATUSES.has(status)) { - if (!FAILED_STATUSES.has(status) && !combineFired) { - combineFired = true; - fetch(`/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}/combine`, { - method: "POST", - }).catch((err) => console.error("Failed to trigger combiner:", err)); - } - - const uploadRes = await fetch( - `/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}` - ); - if (uploadRes.ok) { - const upload: UploadStatus = await uploadRes.json(); - emit({ uploadStatus: upload }); - - if (upload.status === "awaiting_review" && !finalizeFired) { - finalizeFired = true; - fireFinalize(); - } - - if (FINAL_UPLOAD_STATUSES.has(upload.status) && !refreshed) { - refreshed = true; - if (intervalId) clearInterval(intervalId); - intervalId = null; - args.onComplete(); - return; - } - } - } - } catch { - emit({ fetchError: true }); - } - } - - return { - subscribe(listener: () => void) { - listeners.add(listener); - if (listeners.size === 1 && intervalId === null && !refreshed) { - poll(); - intervalId = setInterval(poll, 3000); - } - return () => { - listeners.delete(listener); - if (listeners.size === 0 && intervalId !== null) { - clearInterval(intervalId); - intervalId = null; - } - }; - }, - getSnapshot: () => snapshot, - retryFinalize() { - emit({ finalizeError: null }); - finalizeFired = true; - fireFinalize(); - }, - }; -} +const TASK_TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]); +const TASK_FAILED_STATUSES = new Set(["failed", "failure", "error"]); export default function OnboardingProgress({ - taskId, portfolioSlug, portfolioId, uploadId, isDomnaUser, }: Props) { const router = useRouter(); - const [store] = useState(() => - createProgressStore({ - taskId, - portfolioId, - uploadId, - onComplete: () => router.refresh(), - }) - ); - const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); - const { data, uploadStatus, fetchError, finalizeError } = snap; + const progress = useBulkUploadProgress(portfolioId, uploadId); + const combine = useRequestCombine(portfolioId, uploadId); + const finalize = useFinalize(portfolioId, uploadId); - if (fetchError) return null; - if (!data) { + if (progress.isError) return null; + if (!progress.data) { return (
@@ -175,22 +40,26 @@ export default function OnboardingProgress({ ); } - const total = data.totalSubtasks; - const complete = data.completedSubtasks; - const failed = data.failedSubtasks; - const percent = total > 0 ? Math.round((complete / total) * 100) : 0; - const taskDone = TERMINAL_STATUSES.has(data.status.toLowerCase()); - const isFailed = FAILED_STATUSES.has(data.status.toLowerCase()); - const isCombining = - taskDone && !isFailed && uploadStatus?.status === "combining"; - const isImporting = - taskDone && !isFailed && uploadStatus?.status === "awaiting_review"; + const { task, upload } = progress.data; + const total = task?.totalSubtasks ?? 0; + const completedSubtasks = task?.completedSubtasks ?? 0; + const failedSubtasks = task?.failedSubtasks ?? 0; + const percent = total > 0 ? Math.round((completedSubtasks / total) * 100) : 0; + + const taskStatus = task?.status.toLowerCase() ?? ""; + const taskDone = TASK_TERMINAL_STATUSES.has(taskStatus); + const taskFailed = TASK_FAILED_STATUSES.has(taskStatus); + const isCombining = upload.status === "combining"; + const isImporting = upload.status === "awaiting_review"; + + const canRunCombiner = taskDone && !taskFailed && upload.status === "processing"; + const canFinalize = upload.status === "awaiting_review"; return (
0 ? `${percent}%` : "4%" }} />
@@ -198,13 +67,13 @@ export default function OnboardingProgress({
{total > 0 && ( - {complete} / {total} batches complete + {completedSubtasks} / {total} batches complete )} - {failed > 0 && ( + {failedSubtasks > 0 && ( - {failed} failed + {failedSubtasks} failed )} {!taskDone && ( @@ -222,24 +91,41 @@ export default function OnboardingProgress({ {isImporting && ( - Importing to portfolio… + Awaiting import )}
- {finalizeError && ( -
-
-

Import failed

-

{finalizeError}

-
- + {(canRunCombiner || canFinalize) && ( +
+ {canRunCombiner && ( + combine.mutate()} + /> + )} + {canFinalize && ( + + finalize.mutate(undefined, { onSuccess: () => router.refresh() }) + } + /> + )} +
+ )} + + {combine.error && ( +

{combine.error.message}

+ )} + {finalize.error && ( +
+

Import failed

+

{finalize.error.message}

)} @@ -254,3 +140,37 @@ export default function OnboardingProgress({
); } + +function StageButton({ + label, + activeLabel, + isPending, + onClick, +}: { + label: string; + activeLabel: string; + isPending: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartAddressMatchingButton.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartAddressMatchingButton.tsx index 8f12612..afd6117 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartAddressMatchingButton.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartAddressMatchingButton.tsx @@ -1,8 +1,8 @@ "use client"; import { useRouter } from "next/navigation"; -import { useMutation } from "@tanstack/react-query"; import { ArrowRightIcon } from "@heroicons/react/24/outline"; +import { useStartAddressMatching } from "@/lib/bulkUpload/client"; interface Props { portfolioId: string; @@ -10,53 +10,14 @@ interface Props { filename: string; } -export default function StartAddressMatchingButton({ portfolioId, uploadId, filename }: Props) { +export default function StartAddressMatchingButton({ portfolioId, uploadId }: Props) { const router = useRouter(); - - const { mutate, isPending, error } = useMutation({ - mutationFn: async () => { - const taskRes = await fetch("/api/tasks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - taskSource: `Address Onboarding – ${filename}`, - service: "address2uprn", - source: "portfolio_id", - sourceId: portfolioId, - inputs: { bulk_upload_id: uploadId }, - }), - }); - - if (!taskRes.ok) { - const data = await taskRes.json().catch(() => ({})); - throw new Error(data.error ?? "Failed to create task"); - } - - const { taskId, subTaskId } = await taskRes.json(); - - const matchRes = await fetch( - `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/start-address-matching`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ taskId, subTaskId }), - } - ); - - if (!matchRes.ok) { - const data = await matchRes.json().catch(() => ({})); - throw new Error(data.error ?? "Failed to start address matching"); - } - }, - onSuccess: () => { - router.refresh(); - }, - }); + const { mutate, isPending, error } = useStartAddressMatching(portfolioId, uploadId); return (
{error && (

- {error instanceof Error ? error.message : "Something went wrong"} + {error.message}

)}
diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx index 1915282..adda7d3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx @@ -9,6 +9,7 @@ import { TableCellsIcon, ArrowsRightLeftIcon, } from "@heroicons/react/24/outline"; +import { useSetColumnMapping } from "@/lib/bulkUpload/client"; const INTERNAL_FIELDS = [ { value: "address_1", label: "Address 1", required: true }, @@ -61,42 +62,28 @@ export default function MapColumnsClient({ const [mapping, setMapping] = useState>( buildInitialMapping(sourceHeaders, existingMapping) ); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const setMappingMutation = useSetColumnMapping(portfolioId, uploadId); const mappedValues = Object.values(mapping).filter((v) => v !== "skip"); const missingRequired = REQUIRED_VALUES.filter((r) => !mappedValues.includes(r)); + const submitting = setMappingMutation.isPending; + const error = setMappingMutation.error?.message ?? null; const canSubmit = missingRequired.length === 0 && !submitting; function setField(header: string, value: string) { setMapping((prev) => ({ ...prev, [header]: value })); } - async function handleSubmit() { + function handleSubmit() { if (!canSubmit) return; - setSubmitting(true); - setError(null); - - try { - const res = await fetch( - `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ columnMapping: mapping }), - } - ); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.msg ?? "Failed to save mapping."); + setMappingMutation.mutate( + { columnMapping: mapping }, + { + onSuccess: () => { + router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}`); + }, } - - router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}`); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong."); - setSubmitting(false); - } + ); } return ( diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx index d743097..f8f1a35 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx @@ -181,7 +181,6 @@ export default async function BulkUploadDetailPage(props: { statusKey === "failed") && upload.taskId && ( { + const body = await res.json().catch(() => ({})); + return new Error(body?.error ?? fallback); +} + +export type CreateBulkUploadInput = { + file: File; + portfolioId: string; + userId: string; + sourceHeaders: string[]; + contentType: string; + fileKey: string; + onProgress?: (percent: number) => void; +}; + +export type CreateBulkUploadResult = { + id: string; + s3Key: string; + s3Bucket: string; + status: string; +}; + +export function useCreateBulkUpload() { + return useMutation({ + mutationFn: async (input) => { + const presignRes = await fetch("/api/upload/bulk-addresses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: input.userId, + portfolioId: input.portfolioId, + fileKey: input.fileKey, + contentType: input.contentType, + }), + }); + if (!presignRes.ok) throw await parseError(presignRes, "Failed to generate upload URL."); + const { url: presignedUrl } = await presignRes.json(); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", presignedUrl); + xhr.setRequestHeader("Content-Type", input.contentType); + if (input.onProgress) { + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + input.onProgress!(Math.round((e.loaded / e.total) * 100)); + } + }); + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error(`S3 upload failed: ${xhr.status}`)); + }; + xhr.onerror = () => reject(new Error("Network error during upload")); + xhr.send(input.file); + }); + + const confirmRes = await fetch("/api/upload/bulk-addresses/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileKey: input.fileKey, + filename: input.file.name, + portfolioId: input.portfolioId, + userId: input.userId, + sourceHeaders: input.sourceHeaders, + }), + }); + if (!confirmRes.ok) throw await parseError(confirmRes, "Failed to record upload."); + return confirmRes.json(); + }, + }); +} + +export function useSetColumnMapping(portfolioId: string, uploadId: string) { + const queryClient = useQueryClient(); + return useMutation }>({ + mutationFn: async (input) => { + const res = await fetch(`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) throw await parseError(res, "Failed to save mapping."); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bulkUploadKeys.progress(uploadId) }); + }, + }); +} + +export function useStartAddressMatching(portfolioId: string, uploadId: string) { + const queryClient = useQueryClient(); + return useMutation<{ taskId: string }, Error, void>({ + mutationFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/start-address-matching`, + { method: "POST" }, + ); + if (!res.ok) throw await parseError(res, "Failed to start address matching."); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bulkUploadKeys.progress(uploadId) }); + }, + }); +} + +export function useBulkUploadProgress(portfolioId: string, uploadId: string) { + return useQuery({ + queryKey: bulkUploadKeys.progress(uploadId), + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/progress`, + ); + if (!res.ok) throw await parseError(res, "Failed to load progress."); + return res.json(); + }, + refetchInterval: (data) => { + const status = data?.upload.status; + return status && isTerminalStatus(status) ? false : 3000; + }, + }); +} + +export function useRequestCombine(portfolioId: string, uploadId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/combine`, + { method: "POST" }, + ); + if (!res.ok) throw await parseError(res, "Failed to start combiner."); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bulkUploadKeys.progress(uploadId) }); + }, + }); +} + +export type FinalizeResult = { + inserted?: number; + missingUprnCount?: number; + duplicateUprnCount?: number; + status?: string; + alreadyComplete?: boolean; +}; + +export function useFinalize(portfolioId: string, uploadId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/finalize`, + { method: "POST" }, + ); + if (!res.ok) throw await parseError(res, "Finalize failed."); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bulkUploadKeys.progress(uploadId) }); + }, + }); +} diff --git a/src/lib/bulkUpload/keys.ts b/src/lib/bulkUpload/keys.ts new file mode 100644 index 0000000..1f77cb9 --- /dev/null +++ b/src/lib/bulkUpload/keys.ts @@ -0,0 +1,4 @@ +export const bulkUploadKeys = { + all: ["bulkUpload"] as const, + progress: (uploadId: string) => ["bulkUpload", uploadId, "progress"] as const, +}; diff --git a/src/lib/bulkUpload/server.ts b/src/lib/bulkUpload/server.ts new file mode 100644 index 0000000..a4ce2d5 --- /dev/null +++ b/src/lib/bulkUpload/server.ts @@ -0,0 +1,277 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { tasks } from "@/app/db/schema/tasks/tasks"; +import { subTasks } from "@/app/db/schema/tasks/subtask"; +import { count, desc, eq, sql } from "drizzle-orm"; +import type { BulkUpload, BulkUploadStatus, ProgressView, TaskSummary } from "./types"; + +const REMAP_ALLOWED: ReadonlySet = new Set([ + "ready_for_processing", + "mapping_complete", +]); + +type FastApiTriggerArgs = { + endpoint: string; + payload: Record; + sessionToken: string | undefined; +}; + +type FastApiTriggerResult = { ok: true } | { ok: false; status: number; message: string }; + +async function triggerFastApiPipeline(args: FastApiTriggerArgs): Promise { + const url = process.env.FASTAPI_API_URL; + const key = process.env.FASTAPI_API_KEY; + if (!url || !key) { + console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set"); + return { ok: false, status: 500, message: "Server misconfiguration" }; + } + + try { + const res = await fetch(`${url}${args.endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": key, + Authorization: `Bearer ${args.sessionToken}`, + }, + body: JSON.stringify(args.payload), + }); + if (!res.ok) { + const errText = await res.text().catch(() => ""); + console.error(`FastAPI ${args.endpoint} failed:`, res.status, errText); + return { ok: false, status: 502, message: "Pipeline trigger failed" }; + } + return { ok: true }; + } catch (err) { + console.error(`Failed to reach FastAPI ${args.endpoint}:`, err); + return { ok: false, status: 502, message: "Pipeline trigger failed" }; + } +} + +async function loadById(uploadId: string): Promise { + const [row] = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + return row ?? null; +} + +export async function listForPortfolio(portfolioId: string): Promise { + return db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.portfolioId, portfolioId)) + .orderBy(desc(bulkAddressUploads.createdAt)); +} + +async function loadTaskSummary(taskId: string): Promise { + const [row] = await db + .select({ + id: tasks.id, + taskSource: tasks.taskSource, + status: tasks.status, + service: tasks.service, + jobStarted: tasks.jobStarted, + jobCompleted: tasks.jobCompleted, + updatedAt: tasks.updatedAt, + totalSubtasks: count(subTasks.id), + completedSubtasks: sql`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`, + failedSubtasks: sql`count(case when lower(${subTasks.status}) in ('failed', 'failure', 'error') then 1 end)::int`, + }) + .from(tasks) + .leftJoin(subTasks, eq(subTasks.taskId, tasks.id)) + .where(eq(tasks.id, taskId)) + .groupBy(tasks.id) + .limit(1); + return row ?? null; +} + +export async function getProgressView(uploadId: string): Promise { + const upload = await loadById(uploadId); + if (!upload) return null; + const task = upload.taskId ? await loadTaskSummary(upload.taskId) : null; + return { upload, task }; +} + +function validateMapping(mapping: Record): string | null { + const values = Object.values(mapping); + if (!values.includes("address_1")) return "Mapping must include address_1."; + if (!values.includes("postcode")) return "Mapping must include postcode."; + return null; +} + +export type SetMappingOutcome = + | { kind: "ok"; upload: BulkUpload } + | { kind: "not_found" } + | { kind: "invalid_status"; current: string } + | { kind: "invalid_mapping"; reason: string }; + +export async function setColumnMapping( + uploadId: string, + mapping: Record, +): Promise { + const upload = await loadById(uploadId); + if (!upload) return { kind: "not_found" }; + if (!REMAP_ALLOWED.has(upload.status as BulkUploadStatus)) + return { kind: "invalid_status", current: upload.status }; + + const reason = validateMapping(mapping); + if (reason) return { kind: "invalid_mapping", reason }; + + const [updated] = await db + .update(bulkAddressUploads) + .set({ columnMapping: mapping, status: "mapping_complete" }) + .where(eq(bulkAddressUploads.id, uploadId)) + .returning(); + if (!updated) return { kind: "not_found" }; + return { kind: "ok", upload: updated }; +} + +export type LoadForAddressMatchingOutcome = + | { kind: "ok"; upload: BulkUpload } + | { kind: "not_found" } + | { kind: "wrong_state"; current: string } + | { kind: "missing_mapping" }; + +export async function loadForAddressMatching( + uploadId: string, +): Promise { + const upload = await loadById(uploadId); + if (!upload) return { kind: "not_found" }; + if (upload.status !== "mapping_complete") + return { kind: "wrong_state", current: upload.status }; + if (!upload.columnMapping) return { kind: "missing_mapping" }; + return { kind: "ok", upload }; +} + +export type TriggerAddressMatchingOutcome = + | { kind: "ok"; taskId: string } + | { kind: "trigger_failed"; status: number; message: string }; + +export async function triggerAddressMatching(args: { + uploadId: string; + s3Uri: string; + sessionToken: string | undefined; +}): Promise { + const upload = await loadById(args.uploadId); + if (!upload) return { kind: "trigger_failed", status: 404, message: "Upload not found" }; + + const now = new Date(); + const [task] = await db + .insert(tasks) + .values({ + taskSource: `Address Onboarding – ${upload.filename}`, + service: "address2uprn", + source: "portfolio_id", + sourceId: upload.portfolioId, + status: "waiting", + jobStarted: now, + }) + .returning(); + const [subTask] = await db + .insert(subTasks) + .values({ + taskId: task.id, + status: "waiting", + inputs: JSON.stringify({ bulk_upload_id: args.uploadId }), + }) + .returning(); + + const payload = { + task_id: task.id, + sub_task_id: subTask.id, + s3_uri: args.s3Uri, + }; + + const trigger = await triggerFastApiPipeline({ + endpoint: "/v1/bulk-uploads/trigger-postcode-splitter", + payload, + sessionToken: args.sessionToken, + }); + if (!trigger.ok) + return { kind: "trigger_failed", status: trigger.status, message: trigger.message }; + + await Promise.all([ + db + .update(bulkAddressUploads) + .set({ status: "processing", taskId: task.id }) + .where(eq(bulkAddressUploads.id, args.uploadId)), + db + .update(tasks) + .set({ status: "in progress" }) + .where(eq(tasks.id, task.id)), + db + .update(subTasks) + .set({ inputs: JSON.stringify(payload) }) + .where(eq(subTasks.id, subTask.id)), + ]); + return { kind: "ok", taskId: task.id }; +} + +export type CombineRetriggerOutcome = + | { kind: "triggered"; taskId: string; subTaskId: string } + | { kind: "already_combined" } + | { kind: "not_found" } + | { kind: "missing_task" } + | { kind: "trigger_failed"; status: number; message: string }; + +export async function requestCombineRetrigger(args: { + uploadId: string; + sessionToken: string | undefined; +}): Promise { + const upload = await loadById(args.uploadId); + if (!upload) return { kind: "not_found" }; + if (!upload.taskId) return { kind: "missing_task" }; + if (upload.combinedOutputS3Uri) return { kind: "already_combined" }; + + const [subTask] = await db + .insert(subTasks) + .values({ taskId: upload.taskId, status: "waiting" }) + .returning(); + + const payload = { task_id: upload.taskId, sub_task_id: subTask.id }; + + const trigger = await triggerFastApiPipeline({ + endpoint: "/v1/bulk-uploads/trigger-combiner", + payload, + sessionToken: args.sessionToken, + }); + if (!trigger.ok) + return { kind: "trigger_failed", status: trigger.status, message: trigger.message }; + + await db + .update(subTasks) + .set({ inputs: JSON.stringify(payload) }) + .where(eq(subTasks.id, subTask.id)); + + return { kind: "triggered", taskId: upload.taskId, subTaskId: subTask.id }; +} + +export type LoadForFinalizeOutcome = + | { kind: "ready"; upload: BulkUpload } + | { kind: "already_finalized"; status: "complete" | "needs_review" } + | { kind: "not_found" } + | { kind: "not_yet_combined" } + | { kind: "wrong_state"; current: string }; + +export async function loadForFinalize(uploadId: string): Promise { + const upload = await loadById(uploadId); + if (!upload) return { kind: "not_found" }; + if (upload.status === "complete" || upload.status === "needs_review") + return { kind: "already_finalized", status: upload.status }; + if (upload.status !== "awaiting_review") + return { kind: "wrong_state", current: upload.status }; + if (!upload.combinedOutputS3Uri) return { kind: "not_yet_combined" }; + return { kind: "ready", upload }; +} + +export async function markFinalized( + uploadId: string, + args: { needsReview: boolean }, +): Promise { + await db + .update(bulkAddressUploads) + .set({ status: args.needsReview ? "needs_review" : "complete" }) + .where(eq(bulkAddressUploads.id, uploadId)); +} diff --git a/src/lib/bulkUpload/types.ts b/src/lib/bulkUpload/types.ts new file mode 100644 index 0000000..1559f52 --- /dev/null +++ b/src/lib/bulkUpload/types.ts @@ -0,0 +1,44 @@ +import type { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; + +export const BULK_UPLOAD_STATUSES = [ + "ready_for_processing", + "mapping_complete", + "processing", + "combining", + "awaiting_review", + "complete", + "needs_review", + "failed", +] as const; + +export type BulkUploadStatus = (typeof BULK_UPLOAD_STATUSES)[number]; + +export type BulkUpload = typeof bulkAddressUploads.$inferSelect; + +export type TaskSummary = { + id: string; + taskSource: string; + status: string; + service: string | null; + jobStarted: Date | null; + jobCompleted: Date | null; + updatedAt: Date; + totalSubtasks: number; + completedSubtasks: number; + failedSubtasks: number; +}; + +export type ProgressView = { + upload: BulkUpload; + task: TaskSummary | null; +}; + +const TERMINAL_UPLOAD_STATUSES: ReadonlySet = new Set([ + "complete", + "needs_review", + "failed", +]); + +export function isTerminalStatus(status: string): boolean { + return TERMINAL_UPLOAD_STATUSES.has(status as BulkUploadStatus); +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..92f04dd --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,8 @@ +import { NextRequest } from "next/server"; + +export function readSessionToken(request: NextRequest): string | undefined { + return ( + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value + ); +} From 7ece33b7b66fe6b8338a5a6ea8efd66f64a5d551 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 6 May 2026 16:05:24 +0000 Subject: [PATCH 17/22] removed finalise code as its needed only in the final new process --- CONTEXT.md | 7 ++-- docs/adr/0005-retire-needs-review-status.md | 14 +++++++ .../bulk-uploads/[uploadId]/finalize/route.ts | 39 +++---------------- .../bulk-upload/[uploadId]/page.tsx | 10 +---- src/lib/bulkUpload/client.ts | 11 +----- src/lib/bulkUpload/server.ts | 12 ++---- src/lib/bulkUpload/types.ts | 2 - 7 files changed, 29 insertions(+), 66 deletions(-) create mode 100644 docs/adr/0005-retire-needs-review-status.md diff --git a/CONTEXT.md b/CONTEXT.md index 652d44c..4a95696 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,12 +41,11 @@ ready_for_processing → processing (Address matching triggered; Next.js writes) → combining (Combiner stage running; FastAPI writes directly) → awaiting_review (Combiner output in S3; FastAPI writes directly) - → complete (Finalise succeeded, no row issues; Next.js writes) - → needs_review (Finalise succeeded, missing or duplicate UPRNs; Next.js writes) + → complete (Finalise succeeded; Next.js writes) → failed (FastAPI reports in-flight failure — schema only, not yet wired) ``` -`complete`, `needs_review`, and `failed` are terminal. +`complete` and `failed` are terminal. Re-mapping (PATCHing `columnMapping`) is legal only in `ready_for_processing` and `mapping_complete`. Any later state rejects with 409. @@ -66,7 +65,7 @@ See [ADR-0001](./docs/adr/0001-bulk-upload-state-machine.md) for the deliberate > **Domain expert:** "The BulkUpload sits in `awaiting_review`. The frontend polls and shows a 'review and confirm' button. Nothing's been written to **Properties** yet." > > **Dev:** "And if **Finalise** runs and 30% of rows have no **UPRN**?" -> **Domain expert:** "Those still get imported as **Properties** — just without a UPRN — and the BulkUpload moves to `needs_review`. The `complete` state is only for clean runs." +> **Domain expert:** "Those still get imported as **Properties** — just without a UPRN — and the BulkUpload moves to `complete`. Manual cleanup happens later in the property table." ## Flagged ambiguities diff --git a/docs/adr/0005-retire-needs-review-status.md b/docs/adr/0005-retire-needs-review-status.md new file mode 100644 index 0000000..f12ebb6 --- /dev/null +++ b/docs/adr/0005-retire-needs-review-status.md @@ -0,0 +1,14 @@ +# Retire the `needs_review` BulkUpload status + +[ADR-0001](./0001-bulk-upload-state-machine.md) preserved `needs_review` as a terminal status (deliberate decision #1): a finalised upload with missing or duplicate UPRNs ended up there, distinct from `complete`, even though no recovery flow existed. The reasoning was that the status flag still carried a UI signal — "Imported with issues" copy on the upload detail page — that told the team manual cleanup was needed in the property table. + +This decision retires `needs_review` entirely. Finalise now always sets `complete`, regardless of whether any rows were missing a UPRN or shared one with another row. The status enum, the `markFinalized` signature, and the per-status UI copy are all simplified accordingly. + +The terminal set is now `complete` and `failed` only. The state machine in ADR-0001 otherwise stands; only the post-Finalise branching changes. + +The trade-off was between: + +- **(rejected) Keep `needs_review` for the UI distinction.** The flag was the only consumer of the missing/duplicate-UPRN counts during Finalise, and the counts themselves were already dead in the response payload. Keeping the flag meant maintaining a state-machine branch and a STATUS_CONFIG entry for a signal that — per ADR-0001 — has no follow-up action. Future readers would continue to surface "needs_review has no exit" as friction. +- **(chosen) Always `complete`.** One terminal success state. The Finalise route loses the missing/duplicate-UPRN booleans entirely; rows are inserted with whatever UPRN they have (or none), and the team handles cleanup in the property table directly. If a recovery flow ever lands, it can re-introduce a more meaningful intermediate status at that point — driven by the actual recovery UX rather than a defensive flag set today. + +Legacy `bulk_address_uploads` rows with `status = 'needs_review'` (if any exist) will fall through `STATUS_CONFIG` lookup and render as the default `ready_for_processing` card. This is acceptable because (a) the flow is internal Domna tooling per ADR-0002, (b) any such rows pre-date this change and represent uploads the team has already triaged manually, and (c) the records still link to imported properties via the BulkUpload row's relationships. No migration is run. diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts index b006e92..b6fd7ec 100644 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts @@ -74,10 +74,7 @@ export async function POST( case "not_found": return NextResponse.json({ error: "Not found" }, { status: 404 }); case "already_finalized": - return NextResponse.json( - { alreadyComplete: true, status: guarded.status }, - { status: 200 } - ); + return new NextResponse(null, { status: 200 }); case "wrong_state": return NextResponse.json( { error: `Upload not ready to finalize (state: ${guarded.current})` }, @@ -111,24 +108,12 @@ export async function POST( const portfolioIdBig = BigInt(upload.portfolioId); - const uprnCounts = new Map(); - for (const r of rawRows) { - const v = normalize(r[UPRN_COL]); - if (isMissing(v)) continue; - uprnCounts.set(v, (uprnCounts.get(v) ?? 0) + 1); - } - - let missingUprnCount = 0; - let duplicateUprnCount = 0; - const values = rawRows.map((raw) => { const userInputtedAddress = ADDRESS_COLS.map((c) => normalize(raw[c])).filter(Boolean).join(", ") || null; const userInputtedPostcode = normalize(raw[POSTCODE_COL]) || null; const uprn = parseUprn(raw[UPRN_COL]); - if (uprn === null) missingUprnCount++; - else if ((uprnCounts.get(uprn.toString()) ?? 0) >= 2) duplicateUprnCount++; const matchedAddressRaw = normalize(raw[MATCHED_ADDRESS_COL]); const matchedAddress = isMissing(matchedAddressRaw) ? null : matchedAddressRaw; @@ -152,36 +137,24 @@ export async function POST( }; }); - let inserted = 0; try { if (values.length > 0) { - const result = await db + await db .insert(property) .values(values) .onConflictDoNothing({ target: [property.portfolioId, property.uprn], where: sql`${property.uprn} IS NOT NULL`, - }) - .returning({ id: property.id }); - inserted = result.length; + }); } - const needsReview = missingUprnCount > 0 || duplicateUprnCount > 0; - await markFinalized(uploadId, { needsReview }); - const nextStatus = needsReview ? "needs_review" : "complete"; + await markFinalized(uploadId); revalidatePath("/portfolio/[slug]", "layout"); - return NextResponse.json( - { inserted, missingUprnCount, duplicateUprnCount, status: nextStatus }, - { status: 200 } - ); + return new NextResponse(null, { status: 200 }); } catch (err) { console.error("Failed to finalize bulk upload:", err); - const detail = err instanceof Error ? err.message : String(err); - return NextResponse.json( - { error: "Failed to import properties", detail }, - { status: 500 } - ); + return NextResponse.json({ error: "Failed to import properties" }, { status: 500 }); } } diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx index f8f1a35..5509be2 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx @@ -77,14 +77,6 @@ const STATUS_CONFIG = { body: "All addresses have been imported into your portfolio.", cta: false, }, - needs_review: { - icon: ExclamationCircleIcon, - iconBg: "bg-amber-50", - iconColor: "text-amber-500", - title: "Imported with issues", - body: "Some addresses didn't match a UPRN or matched the same UPRN as another row. Open the properties list to fix them manually.", - cta: false, - }, failed: { icon: ExclamationCircleIcon, iconBg: "bg-red-50", @@ -188,7 +180,7 @@ export default async function BulkUploadDetailPage(props: { /> )} - {(statusKey === "needs_review" || statusKey === "complete") && ( + {statusKey === "complete" && ( ({ + return useMutation({ mutationFn: async () => { const res = await fetch( `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/finalize`, { method: "POST" }, ); if (!res.ok) throw await parseError(res, "Finalize failed."); - return res.json(); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: bulkUploadKeys.progress(uploadId) }); diff --git a/src/lib/bulkUpload/server.ts b/src/lib/bulkUpload/server.ts index a4ce2d5..a5785c3 100644 --- a/src/lib/bulkUpload/server.ts +++ b/src/lib/bulkUpload/server.ts @@ -250,7 +250,7 @@ export async function requestCombineRetrigger(args: { export type LoadForFinalizeOutcome = | { kind: "ready"; upload: BulkUpload } - | { kind: "already_finalized"; status: "complete" | "needs_review" } + | { kind: "already_finalized" } | { kind: "not_found" } | { kind: "not_yet_combined" } | { kind: "wrong_state"; current: string }; @@ -258,20 +258,16 @@ export type LoadForFinalizeOutcome = export async function loadForFinalize(uploadId: string): Promise { const upload = await loadById(uploadId); if (!upload) return { kind: "not_found" }; - if (upload.status === "complete" || upload.status === "needs_review") - return { kind: "already_finalized", status: upload.status }; + if (upload.status === "complete") return { kind: "already_finalized" }; if (upload.status !== "awaiting_review") return { kind: "wrong_state", current: upload.status }; if (!upload.combinedOutputS3Uri) return { kind: "not_yet_combined" }; return { kind: "ready", upload }; } -export async function markFinalized( - uploadId: string, - args: { needsReview: boolean }, -): Promise { +export async function markFinalized(uploadId: string): Promise { await db .update(bulkAddressUploads) - .set({ status: args.needsReview ? "needs_review" : "complete" }) + .set({ status: "complete" }) .where(eq(bulkAddressUploads.id, uploadId)); } diff --git a/src/lib/bulkUpload/types.ts b/src/lib/bulkUpload/types.ts index 1559f52..c2b1cb6 100644 --- a/src/lib/bulkUpload/types.ts +++ b/src/lib/bulkUpload/types.ts @@ -7,7 +7,6 @@ export const BULK_UPLOAD_STATUSES = [ "combining", "awaiting_review", "complete", - "needs_review", "failed", ] as const; @@ -35,7 +34,6 @@ export type ProgressView = { const TERMINAL_UPLOAD_STATUSES: ReadonlySet = new Set([ "complete", - "needs_review", "failed", ]); From 2c8336a6d68defcacdaa804e6a554027631a2d05 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 6 May 2026 16:08:37 +0000 Subject: [PATCH 18/22] claude md file updated --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index e35dd11..588730b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ - **Avoid `useEffect` and `useMemo`.** Derive values inline, use Server Components + Route Handlers, event handlers. If a hook is genuinely the only option, flag it and ask before using it. -- Instead of raw fetch use reactQuery to allow handling of mutations +- Instead of raw fetch use useQuery to allow handling of mutations ## Next.js 15 route handlers From db3609db8557973df5199f9b5278794d445be881 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 6 May 2026 16:11:00 +0000 Subject: [PATCH 19/22] updated claude.md --- CLAUDE.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 588730b..5fd0594 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,16 @@ # Claude guidance for this project +## Project conventions + +- **Domain language lives in [`CONTEXT.md`](./CONTEXT.md).** Read it before naming or discussing BulkUpload, Portfolio, Property, etc. concepts. +- **Architectural decisions live in [`docs/adr/`](./docs/adr/).** Read existing ADRs before proposing changes that touch state machines or core flows. Write a new ADR for any decision that's hard to reverse, surprising without context, and the result of a real trade-off. + ## React -- **Avoid `useEffect` and `useMemo`.** Derive values inline, use Server Components + Route Handlers, event handlers. If a hook is genuinely the only option, flag it and ask before using it. - -- Instead of raw fetch use useQuery to allow handling of mutations +- **Avoid `useEffect` and `useMemo`.** Derive values inline, prefer Server Components + Route Handlers, prefer event handlers. If a hook is genuinely the only option, flag it and ask before using it. +- **Use TanStack Query (`@tanstack/react-query`), not raw `fetch`, for client-side HTTP.** Reads use `useQuery`; writes use `useMutation`. This project is on **v4** — note that `refetchInterval`'s callback signature is `(data, query)`, not v5's `(query)`. ## Next.js 15 route handlers - `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring. +- Error responses use `{ error: string }` consistently. Don't drift to `{ msg }` or other shapes. From 131dd2736ed1739b3a0fa4a0fc10bba67d713e31 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 6 May 2026 16:12:23 +0000 Subject: [PATCH 20/22] remove backlog stuff --- run_backlog_browser.sh | 1 - 1 file changed, 1 deletion(-) delete mode 100644 run_backlog_browser.sh diff --git a/run_backlog_browser.sh b/run_backlog_browser.sh deleted file mode 100644 index bd3fcc4..0000000 --- a/run_backlog_browser.sh +++ /dev/null @@ -1 +0,0 @@ -backlog browser From 61b1f89bd9419f8990125768c20908c12ea9f7fc Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 6 May 2026 16:13:30 +0000 Subject: [PATCH 21/22] got rid of backlog port --- .devcontainer/docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 90cedb7..296beb9 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -10,7 +10,6 @@ services: command: sleep infinity ports: - "3000:3000" - - "6420:6420" volumes: - ..:/workspaces/assessment-model - ~/.gitconfig:/home/vscode/.gitconfig From b387bc24f8933704fb3629e24345403989e8fe1f Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 12 May 2026 13:54:18 +0000 Subject: [PATCH 22/22] got rid of get server --- .../[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx index 5509be2..f28787f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx @@ -1,5 +1,3 @@ -"use server"; - import { db } from "@/app/db/db"; import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; import { eq } from "drizzle-orm";