Merge pull request #243 from Hestia-Homes/feature/onbarding_of_addresses
Some checks are pending
Test Suite / unit-tests (push) Waiting to run

Feature/onbarding of addresses
This commit is contained in:
Jun-te Kim 2026-05-12 18:26:40 +01:00 committed by GitHub
commit 6f9fabb622
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2394 additions and 6963 deletions

View file

@ -23,6 +23,7 @@ services:
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
networks:
- frontend-net
- shared-dev
pgadmin:
image: dpage/pgadmin4
@ -38,3 +39,6 @@ services:
networks:
frontend-net:
driver: bridge
shared-dev:
external: true
name: shared-dev

4
.gitignore vendored
View file

@ -37,3 +37,7 @@ cypress.env.json
# typescript
*.tsbuildinfo
next-env.d.ts
backlog/**
docs/adr/**

16
CLAUDE.md Normal file
View file

@ -0,0 +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, 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.

73
CONTEXT.md Normal file
View file

@ -0,0 +1,73 @@
# 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; Next.js writes)
→ failed (FastAPI reports in-flight failure — schema only, not yet wired)
```
`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.
**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 `complete`. Manual cleanup happens later in the property table."
## 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.

View file

@ -0,0 +1,35 @@
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";
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 result = await requestCombineRetrigger({
uploadId,
sessionToken: readSessionToken(request),
});
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 });
}
}

View file

@ -0,0 +1,160 @@
import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
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 { 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";
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 guarded = await loadForFinalize(uploadId);
switch (guarded.kind) {
case "not_found":
return NextResponse.json({ error: "Not found" }, { status: 404 });
case "already_finalized":
return new NextResponse(null, { 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;
const parsed = parseS3Uri(upload.combinedOutputS3Uri!);
if (!parsed) {
return NextResponse.json({ error: "Invalid combined output S3 URI" }, { status: 500 });
}
const s3 = createRetrofitDataS3Client();
let rawRows: Record<string, unknown>[];
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<Record<string, unknown>>(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 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]);
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,
};
});
try {
if (values.length > 0) {
await db
.insert(property)
.values(values)
.onConflictDoNothing({
target: [property.portfolioId, property.uprn],
where: sql`${property.uprn} IS NOT NULL`,
});
}
await markFinalized(uploadId);
revalidatePath("/portfolio/[slug]", "layout");
return new NextResponse(null, { status: 200 });
} catch (err) {
console.error("Failed to finalize bulk upload:", err);
return NextResponse.json({ error: "Failed to import properties" }, { status: 500 });
}
}

View file

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

View file

@ -0,0 +1,41 @@
import { setColumnMapping } from "@/lib/bulkUpload/server";
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({ error: "Invalid input" }, { status: 400 });
}
try {
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 });
}
} catch (error) {
console.error("Failed to save column mapping:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
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<string, string> = {
address_1: "Address 1",
address_2: "Address 2",
address_3: "Address 3",
postcode: "postcode",
internal_reference: "Internal Reference",
};
function transformFile(
buffer: Buffer,
columnMapping: Record<string, string>
): { 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<Record<string, unknown>>(sheet, { defval: "" });
if (rows.length === 0) return { error: "Empty file" };
const sourceHeaders = Object.keys(rows[0]);
const outputHeaders: string[] = [];
const sourceToOutput: Record<string, string> = {};
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<string, unknown> = {};
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;
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 = guarded.upload;
const s3 = createS3Client();
const outputS3 = createRetrofitDataS3Client();
const outputBucket = retrofitDataS3Bucket();
let fileBuffer: Buffer;
try {
const obj = await s3
.getObject({ Bucket: upload.s3Bucket, 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 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 {
await outputS3
.putObject({
Bucket: outputBucket,
Key: transformedKey,
Body: transformed.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 trigger = await triggerAddressMatching({
uploadId,
s3Uri,
sessionToken: readSessionToken(request),
});
if (trigger.kind === "trigger_failed")
return NextResponse.json({ error: trigger.message }, { status: trigger.status });
return NextResponse.json({ taskId: trigger.taskId }, { status: 200 });
}

View file

@ -0,0 +1,17 @@
import { listForPortfolio } from "@/lib/bulkUpload/server";
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 listForPortfolio(portfolioId);
return NextResponse.json(uploads, { status: 200 });
} catch (error) {
console.error("Failed to fetch bulk uploads:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -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<number>`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`,
failedSubtasks: sql<number>`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 });
}
}

View file

@ -0,0 +1,55 @@
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.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) {
let body;
try {
body = BodySchema.parse(await request.json());
} catch (error) {
console.error("Invalid input:", error);
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({ error: "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({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -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({ error: "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({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -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 (
<>
<BulkUploadComingSoonModal
@ -118,7 +128,7 @@ export default function AddNew({
<MenuItem>
{({ active }) => (
<button
onClick={() => setIsBulkUploadOpen(true)}
onClick={handleBulkUploadClick}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100"

View file

@ -4,11 +4,33 @@ 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";
import { useCreateBulkUpload } from "@/lib/bulkUpload/client";
const MAX_FILE_SIZE_MB = 50;
const ALLOWED_EXTENSIONS = [".csv", ".xlsx", ".xls"];
const CONTENT_TYPES: Record<string, string> = {
".csv": "text/csv",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
};
interface BulkUploadComingSoonModalProps {
isOpen: boolean;
@ -16,13 +38,180 @@ 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<unknown[]>(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");
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 };
}
export default function BulkUploadComingSoonModal({
isOpen,
onClose,
portfolioId,
}: BulkUploadComingSoonModalProps) {
const session = useSession();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const createUpload = useCreateBulkUpload();
const [isDragging, setIsDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [sourceHeaders, setSourceHeaders] = useState<string[]>([]);
const [validationError, setValidationError] = useState<string | null>(null);
const [validating, setValidating] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(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) {
createUpload.reset();
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<HTMLDivElement>) {
e.preventDefault();
setIsDragging(true);
}
function handleDragLeave() {
setIsDragging(false);
}
function handleDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
}
function handleClose() {
setSelectedFile(null);
setSourceHeaders([]);
setValidationError(null);
setValidating(false);
setUploadProgress(null);
createUpload.reset();
onClose();
}
function handleUpload() {
const userId = String(session.data?.user?.dbId ?? "");
if (!selectedFile || !userId) return;
const ext = getFileExtension(selectedFile.name);
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
const fileKey = generateS3Key(userId, portfolioId, ext);
setUploadProgress(0);
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;
return (
<Transition show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[9999]" onClose={handleClose}>
{/* Backdrop */}
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
@ -32,52 +221,204 @@ export default function BulkUploadComingSoonModal({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<DialogBackdrop className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
<DialogBackdrop className="fixed inset-0 bg-gray-900/40 backdrop-blur-sm" />
</TransitionChild>
{/* Panel */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
enterFrom="opacity-0 scale-95 translate-y-2"
enterTo="opacity-100 scale-100 translate-y-0"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
leaveFrom="opacity-100 scale-100 translate-y-0"
leaveTo="opacity-0 scale-95 translate-y-2"
>
<DialogPanel className="w-full max-w-md bg-white rounded-2xl shadow-xl p-8 relative">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
<div className="flex flex-col items-center text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center">
<RectangleStackIcon className="h-7 w-7 text-amber-500" />
</div>
<DialogPanel className="w-full max-w-2xl bg-white rounded-2xl shadow-[0_40px_60px_-15px_rgba(21,29,33,0.15)] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-10 pt-10 pb-6 flex justify-between items-start">
<div>
<span className="text-[11px] font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full">
Coming Soon
</span>
<h2 className="mt-3 text-2xl font-extrabold text-gray-900 tracking-tight">
Bulk Address Upload
</h2>
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
Upload multiple addresses in one go. This feature is currently in development
and will be available soon.
<DialogTitle className="text-2xl font-extrabold text-gray-900 tracking-tight mb-2">
Bulk Upload: New Properties
</DialogTitle>
<p className="text-sm text-gray-500 leading-relaxed max-w-md">
This workflow is designed for adding new residential or commercial
assets to your portfolio. Upload your dataset to begin the
transformation.
</p>
</div>
<button
onClick={onClose}
className="mt-2 px-6 py-2.5 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
onClick={handleClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors ml-4 shrink-0"
>
Got it
<XMarkIcon className="h-5 w-5 text-gray-400" />
</button>
</div>
{/* Content */}
<div className="px-10 py-2 space-y-5">
{/* Template section */}
<div className="bg-gray-50 p-5 rounded-xl flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-11 h-11 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
<DocumentTextIcon className="h-6 w-6 text-midblue" />
</div>
<div>
<p className="text-sm font-semibold text-gray-900">Required Template Format</p>
<p className="text-xs text-gray-500 mt-0.5">
Must contain:{" "}
<span className="font-medium text-midblue">
Address, Postcode
</span>
</p>
</div>
</div>
<button
onClick={downloadTemplate}
className="flex items-center gap-1.5 text-xs font-semibold text-midblue hover:text-gray-900 transition-colors shrink-0 ml-4"
>
<ArrowDownTrayIcon className="h-4 w-4" />
Download template
</button>
</div>
{/* Dropzone */}
<div
onClick={() => !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"
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleInputChange}
onClick={(e) => e.stopPropagation()}
/>
{validating ? (
<>
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<span className="h-6 w-6 rounded-full border-2 border-midblue border-t-transparent animate-spin" />
</div>
<p className="text-base font-bold text-gray-900 mb-1">Checking headers</p>
<p className="text-xs text-gray-400">Validating column structure</p>
</>
) : uploading ? (
<>
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<CloudArrowUpIcon className="h-7 w-7 text-midblue" />
</div>
<p className="text-base font-bold text-gray-900 mb-1">Uploading</p>
<p className="text-xs text-gray-400 mb-4">{selectedFile?.name}</p>
<div className="w-full max-w-xs bg-gray-200 rounded-full h-2">
<div
className="bg-midblue h-2 rounded-full transition-all duration-200"
style={{ width: `${uploadProgress ?? 0}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{uploadProgress ?? 0}%</p>
</>
) : validationError ? (
<>
<ExclamationCircleIcon className="h-14 w-14 text-red-400 mb-4" />
<p className="text-base font-bold text-red-600 mb-1">{validationError}</p>
<p className="text-xs text-gray-400">Click to choose a different file</p>
</>
) : selectedFile ? (
<>
<CheckCircleIcon className="h-14 w-14 text-green-400 mb-4" />
<p className="text-base font-bold text-gray-900 mb-1">{selectedFile.name}</p>
<p className="text-xs text-gray-400">Click to change file</p>
</>
) : (
<>
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<CloudArrowUpIcon className="h-7 w-7 text-midblue" />
</div>
<p className="text-base font-bold text-gray-900 mb-1">
Drag and drop CSV or XLSX
</p>
<p className="text-xs text-gray-400">
or <span className="text-midblue font-semibold">click to browse</span> · Max {MAX_FILE_SIZE_MB}MB
</p>
</>
)}
</div>
{/* Upload error */}
{uploadError && (
<p className="text-xs text-red-500 flex items-center gap-1.5">
<ExclamationCircleIcon className="h-4 w-4 shrink-0" />
{uploadError}
</p>
)}
{/* Info strip */}
<div className="flex items-center gap-3 text-xs text-gray-500 bg-gray-50 px-4 py-3 rounded-lg">
<InformationCircleIcon className="h-4 w-4 text-midblue shrink-0" />
<span>
Properties will be automatically validated against national
architectural databases.
</span>
</div>
</div>
{/* Footer */}
<div className="px-10 py-7 mt-4 flex items-center justify-between bg-gray-50/50">
<div className="flex items-center gap-4">
<button
onClick={handleClose}
className="text-sm font-semibold text-gray-400 hover:text-gray-700 transition-colors"
>
Cancel and Exit
</button>
<button
onClick={() => { handleClose(); router.push(`/portfolio/${portfolioId}/bulk-upload`); }}
className="text-sm font-semibold text-midblue hover:text-gray-900 transition-colors"
>
View previous uploads
</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleUpload}
disabled={!canUpload}
className={`flex items-center gap-2 px-7 py-2.5 rounded-2xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
canUpload ? "opacity-100 hover:opacity-90" : "opacity-40 cursor-not-allowed"
}`}
>
{uploading ? (
<>
<span className="h-4 w-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
Uploading
</>
) : (
<>
Upload File
<ArrowRightIcon className="h-4 w-4" />
</>
)}
</button>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>

View file

@ -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;

View file

@ -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",
"25",
"620",
"2\u20135",
"6\u201320",
"21+",
"150",
"51100",
"101300",
"3011000",
"1\u201350",
"51\u2013100",
"101\u2013300",
"301\u20131000",
"1000+"
]
},

File diff suppressed because it is too large Load diff

View file

@ -386,6 +386,7 @@ export interface PropertyWithRelations extends Record<string, unknown> {
totalFloorArea: number | null;
co2Emissions: number | null;
mainfuel: string | null;
lexiscore: number | null;
}
export type NonIntrusiveSurveyNotes = InferModel<

View file

@ -0,0 +1,176 @@
"use client";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import {
useBulkUploadProgress,
useFinalize,
useRequestCombine,
} from "@/lib/bulkUpload/client";
interface Props {
portfolioSlug: string;
portfolioId: string;
uploadId: string;
isDomnaUser: boolean;
}
const TASK_TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]);
const TASK_FAILED_STATUSES = new Set(["failed", "failure", "error"]);
export default function OnboardingProgress({
portfolioSlug,
portfolioId,
uploadId,
isDomnaUser,
}: Props) {
const router = useRouter();
const progress = useBulkUploadProgress(portfolioId, uploadId);
const combine = useRequestCombine(portfolioId, uploadId);
const finalize = useFinalize(portfolioId, uploadId);
if (progress.isError) return null;
if (!progress.data) {
return (
<div className="mt-4 flex items-center gap-2 text-sm text-gray-400">
<span className="w-4 h-4 rounded-full border-2 border-gray-300 border-t-transparent animate-spin" />
Loading progress
</div>
);
}
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 (
<div className="mt-6 space-y-3">
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ${taskFailed ? "bg-red-400" : "bg-[#14163d]"}`}
style={{ width: total > 0 ? `${percent}%` : "4%" }}
/>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
{total > 0 && (
<span>
<span className="font-semibold text-gray-700">{completedSubtasks}</span> / {total} batches complete
</span>
)}
{failedSubtasks > 0 && (
<span className="flex items-center gap-1 text-red-500 font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
{failedSubtasks} failed
</span>
)}
{!taskDone && (
<span className="flex items-center gap-1 text-blue-500">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
Running
</span>
)}
{isCombining && (
<span className="flex items-center gap-1 text-blue-500">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
Combining results
</span>
)}
{isImporting && (
<span className="flex items-center gap-1 text-blue-500">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
Awaiting import
</span>
)}
</div>
{(canRunCombiner || canFinalize) && (
<div className="flex flex-col gap-2 pt-2">
{canRunCombiner && (
<StageButton
label="Run Combiner"
activeLabel="Starting combiner…"
isPending={combine.isPending}
onClick={() => combine.mutate()}
/>
)}
{canFinalize && (
<StageButton
label="Finalise"
activeLabel="Finalising…"
isPending={finalize.isPending}
onClick={() =>
finalize.mutate(undefined, { onSuccess: () => router.refresh() })
}
/>
)}
</div>
)}
{combine.error && (
<p className="text-xs text-red-500">{combine.error.message}</p>
)}
{finalize.error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
<p className="font-semibold">Import failed</p>
<p className="text-red-600 mt-0.5 break-words">{finalize.error.message}</p>
</div>
)}
{isDomnaUser && (
<Link
href={`/portfolio/${portfolioSlug}/settings/logs`}
className="text-xs text-gray-400 hover:text-gray-700 underline underline-offset-2 transition-colors"
>
View detailed logs
</Link>
)}
</div>
);
}
function StageButton({
label,
activeLabel,
isPending,
onClick,
}: {
label: string;
activeLabel: string;
isPending: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
disabled={isPending}
className={`inline-flex items-center gap-2 self-start px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
isPending ? "opacity-50 cursor-not-allowed" : "hover:opacity-90"
}`}
>
{isPending ? (
<>
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
{activeLabel}
</>
) : (
<>
{label}
<ArrowRightIcon className="h-4 w-4" />
</>
)}
</button>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { useRouter } from "next/navigation";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import { useStartAddressMatching } from "@/lib/bulkUpload/client";
interface Props {
portfolioId: string;
uploadId: string;
filename: string;
}
export default function StartAddressMatchingButton({ portfolioId, uploadId }: Props) {
const router = useRouter();
const { mutate, isPending, error } = useStartAddressMatching(portfolioId, uploadId);
return (
<div className="mt-4">
<button
onClick={() => mutate(undefined, { onSuccess: () => router.refresh() })}
disabled={isPending}
className={`inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
isPending ? "opacity-50 cursor-not-allowed" : "hover:opacity-90"
}`}
>
{isPending ? (
<>
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
Starting
</>
) : (
<>
Start Onboarding
<ArrowRightIcon className="h-4 w-4" />
</>
)}
</button>
{error && (
<p className="mt-2 text-xs text-red-500">
{error.message}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,258 @@
"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";
import { useSetColumnMapping } from "@/lib/bulkUpload/client";
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<string, string>
): Record<string, string> {
const mapping: Record<string, string> = {};
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<string, string>;
}
export default function MapColumnsClient({
portfolioId,
uploadId,
filename,
sourceHeaders,
existingMapping,
}: Props) {
const router = useRouter();
const [mapping, setMapping] = useState<Record<string, string>>(
buildInitialMapping(sourceHeaders, existingMapping)
);
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 }));
}
function handleSubmit() {
if (!canSubmit) return;
setMappingMutation.mutate(
{ columnMapping: mapping },
{
onSuccess: () => {
router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}`);
},
}
);
}
return (
<div className="max-w-4xl mx-auto px-6 py-10">
{/* Breadcrumb + step */}
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Bulk Uploads Column Remapper
</p>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Step 2 of 3
</span>
<div className="flex gap-1 ml-2">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-1.5 rounded-full ${
s <= 2 ? "w-8 bg-[#14163d]" : "w-8 bg-gray-200"
}`}
/>
))}
</div>
</div>
</div>
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight mb-1">
Column Remapper
</h1>
<p className="text-sm text-gray-500 max-w-lg">
Align your spreadsheet headers with our internal property data structure to
ensure accurate address processing.
</p>
</div>
{/* Table */}
<div className="bg-white border border-gray-100 rounded-2xl overflow-hidden shadow-sm mb-6">
{/* Column headers */}
<div className="grid grid-cols-12 items-center px-6 py-3 bg-gray-50 border-b border-gray-100">
<span className="col-span-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">
Spreadsheet Header
</span>
<span className="col-span-1" />
<span className="col-span-5 text-xs font-semibold text-gray-400 uppercase tracking-wider">
Internal Field Mapping
</span>
<span className="col-span-2 text-xs font-semibold text-gray-400 uppercase tracking-wider text-right">
Status
</span>
</div>
{sourceHeaders.length === 0 ? (
<div className="px-6 py-12 text-center text-sm text-gray-400">
No headers found in this file.
</div>
) : (
<div className="divide-y divide-gray-50">
{sourceHeaders.map((header) => {
const value = mapping[header] ?? "skip";
const isMapped = value !== "skip";
return (
<div
key={header}
className="grid grid-cols-12 items-center px-6 py-4 hover:bg-gray-50/50 transition-colors"
>
{/* Source header */}
<div className="col-span-4 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
<TableCellsIcon className="h-4 w-4 text-gray-400" />
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{header}</p>
<p className="text-xs text-gray-400">Source column</p>
</div>
</div>
{/* Arrow */}
<div className="col-span-1 flex justify-center">
<ArrowsRightLeftIcon className="h-4 w-4 text-gray-300" />
</div>
{/* Dropdown */}
<div className="col-span-5">
<select
value={value}
onChange={(e) => setField(header, e.target.value)}
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-800 focus:outline-none focus:ring-2 focus:ring-[#14163d]/20 focus:border-[#14163d]"
>
{INTERNAL_FIELDS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
{/* Status badge */}
<div className="col-span-2 flex justify-end">
<span
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold ${
isMapped
? "bg-amber-50 text-amber-700"
: "bg-gray-100 text-gray-400"
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-70" />
{isMapped ? "Mapped" : "Skipped"}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Validation error */}
{missingRequired.length > 0 && (
<p className="text-xs text-amber-600 mb-4">
Required fields not yet mapped:{" "}
{missingRequired
.map((r) => INTERNAL_FIELDS.find((f) => f.value === r)?.label)
.join(", ")}
</p>
)}
{error && <p className="text-xs text-red-500 mb-4">{error}</p>}
{/* Footer */}
<div className="flex items-center justify-between">
<Link
href={`/portfolio/${portfolioId}/bulk-upload`}
className="flex items-center gap-1.5 text-sm font-semibold text-gray-400 hover:text-gray-700 transition-colors"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</Link>
<div className="flex items-center gap-3">
<Link
href={`/portfolio/${portfolioId}/bulk-upload`}
className="text-sm font-semibold text-gray-400 hover:text-gray-700 transition-colors"
>
Cancel
</Link>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className={`flex items-center gap-2 px-7 py-2.5 rounded-2xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
canSubmit ? "opacity-100 hover:opacity-90" : "opacity-40 cursor-not-allowed"
}`}
>
{submitting ? "Saving…" : "Process Mapping"}
{!submitting && <ArrowRightIcon className="h-4 w-4" />}
</button>
</div>
</div>
{/* Pro tip */}
<div className="mt-10 bg-gray-50 rounded-2xl p-6">
<p className="text-xs font-semibold text-midblue uppercase tracking-wider mb-2">
Pro Tip
</p>
<p className="text-sm text-gray-500 italic">
&ldquo;Ensure your source file doesn&apos;t have blank headers. Any column mapped to
&ldquo;Skip&rdquo; will be ignored during import.&rdquo;
</p>
</div>
</div>
);
}

View file

@ -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 (
<MapColumnsClient
portfolioId={slug}
uploadId={uploadId}
filename={upload.filename}
sourceHeaders={upload.sourceHeaders}
existingMapping={upload.columnMapping ?? undefined}
/>
);
}

View file

@ -0,0 +1,196 @@
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 StartAddressMatchingButton from "./StartAddressMatchingButton";
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,
},
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: ArrowPathIcon,
iconBg: "bg-blue-50",
iconColor: "text-blue-500",
title: "Importing addresses…",
body: "Matches ready, writing into your portfolio.",
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 (
<div className="max-w-2xl mx-auto px-6 py-10">
{/* Back */}
<Link
href={`/portfolio/${slug}/bulk-upload`}
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-700 transition-colors mb-8"
>
<ArrowLeftIcon className="h-4 w-4" />
Back to uploads
</Link>
{/* Header */}
<div className="mb-8">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Bulk Upload
</p>
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight mb-1">
{upload.filename}
</h1>
<p className="text-sm text-gray-500">Uploaded {formatDate(upload.createdAt)}</p>
</div>
{/* Status card */}
<div className="bg-white border border-gray-100 rounded-2xl p-6 shadow-sm mb-4">
<div className="flex items-start gap-4">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${config.iconBg}`}>
<Icon className={`h-6 w-6 ${config.iconColor}`} />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900 mb-1">{config.title}</p>
<p className="text-sm text-gray-500 leading-relaxed">{config.body}</p>
{statusKey === "ready_for_processing" && (
<Link
href={`/portfolio/${slug}/bulk-upload/${uploadId}/map-columns`}
className="mt-4 inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
>
Map Columns
<ArrowRightIcon className="h-4 w-4" />
</Link>
)}
{statusKey === "mapping_complete" && (
<div className="mt-4 flex flex-col gap-3">
<Link
href={`/portfolio/${slug}/bulk-upload/${uploadId}/map-columns`}
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-700 transition-colors"
>
Edit column mapping
<ArrowRightIcon className="h-3.5 w-3.5" />
</Link>
<StartAddressMatchingButton
portfolioId={upload.portfolioId}
uploadId={uploadId}
filename={upload.filename}
/>
</div>
)}
{(statusKey === "processing" ||
statusKey === "combining" ||
statusKey === "awaiting_review" ||
statusKey === "complete" ||
statusKey === "failed") &&
upload.taskId && (
<OnboardingProgress
portfolioSlug={slug}
portfolioId={upload.portfolioId}
uploadId={uploadId}
isDomnaUser={isDomnaUser}
/>
)}
{statusKey === "complete" && (
<a
href={`/portfolio/${slug}`}
className="mt-4 inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
>
Open properties
<ArrowRightIcon className="h-4 w-4" />
</a>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,141 @@
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<string, { label: string; classes: string }> = {
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 (
<div className="max-w-4xl mx-auto px-6 py-10">
{/* Header */}
<div className="mb-8">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Portfolio Bulk Uploads
</p>
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
Batch Uploads
</h1>
<p className="text-sm text-gray-500 mt-1">
Select an upload to continue processing, or start a new import.
</p>
</div>
{uploads.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center py-24 border-2 border-dashed border-gray-200 rounded-2xl text-center">
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<CloudArrowUpIcon className="h-7 w-7 text-gray-400" />
</div>
<p className="text-base font-semibold text-gray-700 mb-1">No uploads yet</p>
<p className="text-sm text-gray-400">
Use the Bulk Upload button on your portfolio to get started.
</p>
</div>
) : (
/* Upload list */
<div className="space-y-3">
{/* Column headers */}
<div className="grid grid-cols-12 px-4 pb-1">
<span className="col-span-6 text-xs font-semibold text-gray-400 uppercase tracking-wider">
File
</span>
<span className="col-span-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">
Uploaded
</span>
<span className="col-span-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">
Status
</span>
<span className="col-span-1" />
</div>
{uploads.map((upload) => {
const status = STATUS_LABELS[upload.status] ?? {
label: upload.status,
classes: "bg-gray-100 text-gray-600",
};
return (
<Link
key={upload.id}
href={`/portfolio/${slug}/bulk-upload/${upload.id}`}
className="grid grid-cols-12 items-center bg-white border border-gray-100 rounded-xl px-4 py-4 hover:border-gray-300 hover:shadow-sm transition-all group"
>
{/* Filename */}
<div className="col-span-6 flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-gray-50 border border-gray-100 flex items-center justify-center shrink-0">
<DocumentTextIcon className="h-5 w-5 text-midblue" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">
{upload.filename}
</p>
<p className="text-xs text-gray-400 truncate font-mono">
{upload.s3Key.split("/").pop()}
</p>
</div>
</div>
{/* Date */}
<div className="col-span-3">
<p className="text-sm text-gray-500">
{formatDate(upload.createdAt)}
</p>
</div>
{/* Status badge */}
<div className="col-span-2">
<span
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold ${status.classes}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-70" />
{status.label}
</span>
</div>
{/* Arrow */}
<div className="col-span-1 flex justify-end">
<ArrowRightIcon className="h-4 w-4 text-gray-300 group-hover:text-gray-600 transition-colors" />
</div>
</Link>
);
})}
</div>
)}
</div>
);
}

View file

@ -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};
`);

View file

@ -20,6 +20,20 @@ export function createS3Client(config?: S3Config) {
});
}
export function createRetrofitDataS3Client() {
return 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,
});
}
export function retrofitDataS3Bucket(): string {
const bucket = process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME;
if (!bucket) throw new Error("RETROFIT_DATA_DEV_S3_BUCKET_NAME not set");
return bucket;
}
// Get presigned url from s3
export type PresignGetOptions = {

View file

@ -16,19 +16,20 @@ const sqsClient = new SQSClient({
},
});
let cachedQueueUrl: string | null = null;
const queueUrlCache = new Map<string, string>();
// Export if you want to reuse elsewhere
export async function getQueueUrl(queueName: string): Promise<string> {
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 = {

View file

@ -0,0 +1,164 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { bulkUploadKeys } from "./keys";
import { isTerminalStatus, type BulkUpload, type ProgressView } from "./types";
async function parseError(res: Response, fallback: string): Promise<Error> {
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<CreateBulkUploadResult, Error, CreateBulkUploadInput>({
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<void>((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<BulkUpload, Error, { columnMapping: Record<string, string> }>({
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<ProgressView, Error>({
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<unknown, Error, void>({
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 function useFinalize(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/finalize`,
{ method: "POST" },
);
if (!res.ok) throw await parseError(res, "Finalize failed.");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bulkUploadKeys.progress(uploadId) });
},
});
}

View file

@ -0,0 +1,4 @@
export const bulkUploadKeys = {
all: ["bulkUpload"] as const,
progress: (uploadId: string) => ["bulkUpload", uploadId, "progress"] as const,
};

View file

@ -0,0 +1,273 @@
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<BulkUploadStatus> = new Set([
"ready_for_processing",
"mapping_complete",
]);
type FastApiTriggerArgs = {
endpoint: string;
payload: Record<string, unknown>;
sessionToken: string | undefined;
};
type FastApiTriggerResult = { ok: true } | { ok: false; status: number; message: string };
async function triggerFastApiPipeline(args: FastApiTriggerArgs): Promise<FastApiTriggerResult> {
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<BulkUpload | null> {
const [row] = await db
.select()
.from(bulkAddressUploads)
.where(eq(bulkAddressUploads.id, uploadId))
.limit(1);
return row ?? null;
}
export async function listForPortfolio(portfolioId: string): Promise<BulkUpload[]> {
return db
.select()
.from(bulkAddressUploads)
.where(eq(bulkAddressUploads.portfolioId, portfolioId))
.orderBy(desc(bulkAddressUploads.createdAt));
}
async function loadTaskSummary(taskId: string): Promise<TaskSummary | null> {
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<number>`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`,
failedSubtasks: sql<number>`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<ProgressView | null> {
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, string>): 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<string, string>,
): Promise<SetMappingOutcome> {
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<LoadForAddressMatchingOutcome> {
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<TriggerAddressMatchingOutcome> {
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<CombineRetriggerOutcome> {
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" }
| { kind: "not_found" }
| { kind: "not_yet_combined" }
| { kind: "wrong_state"; current: string };
export async function loadForFinalize(uploadId: string): Promise<LoadForFinalizeOutcome> {
const upload = await loadById(uploadId);
if (!upload) return { kind: "not_found" };
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): Promise<void> {
await db
.update(bulkAddressUploads)
.set({ status: "complete" })
.where(eq(bulkAddressUploads.id, uploadId));
}

View file

@ -0,0 +1,42 @@
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",
"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<BulkUploadStatus> = new Set([
"complete",
"failed",
]);
export function isTerminalStatus(status: string): boolean {
return TERMINAL_UPLOAD_STATUSES.has(status as BulkUploadStatus);
}

8
src/lib/session.ts Normal file
View file

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