fixed merge

This commit is contained in:
Khalim Conn-Kowlessar 2025-08-21 16:07:32 +00:00
commit c85d3956ce
30 changed files with 2090 additions and 399 deletions

View file

@ -1,6 +1,6 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
### Getting Started
When first getting set up you'll firstly want to install the existing dependencies. To do this, simply run

View file

@ -19,7 +19,7 @@ import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { mount } from 'cypress/react18'
import { mount } from 'cypress/react'
// Augment the Cypress namespace to include type definitions for
// your custom command.

1295
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@
"create_user": "tsx src/app/db/create_user.ts"
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.864.0",
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.9.1",

1
run_build.sh Normal file
View file

@ -0,0 +1 @@
npm run build

View file

@ -27,7 +27,7 @@ export async function POST(request: NextRequest) {
const s3 = new S3({
signatureVersion: "v4",
region: process.env.PRESIGN_AWS_REGION,
accessKeyId: process.env.PRSIGN_AWS_ACCESS_KEY,
accessKeyId: process.env.PRESIGN_AWS_ACCESS_KEY,
secretAccessKey: process.env.PRESIGN_AWS_SECRET_KEY,
});
@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
// Presigned url is valid for 5 minutes
const preSignedUrl = await s3.getSignedUrl("putObject", {
Bucket: process.env.RETOFIT_PLAN_INPUT_BUCKET_NAME,
Bucket: process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME,
Key: fileKey,
ContentType: "text/csv",
Expires: 5 * 60,

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createS3Client, presignGetUrl } from "@/app/utils/s3";
const Schema = z.object({
path: z.string(),
expiresInSeconds: z.number().int().positive().default(300),
contentType: z.string(),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { path, expiresInSeconds, contentType } =
Schema.parse(body);
// Retrofit s3 bucket connection
let bucket = process.env.RETROFIT_ENERGY_ASSESSMENTS_BUCKET
// let bucket = process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME
if (!bucket) {
return NextResponse.json({ msg: "RETROFIT_ENERGY_ASSESSMENTS_BUCKET is not set" }, { status: 400 });
}
const s3 = createS3Client({
region: process.env.PRESIGN_AWS_REGION,
accessKeyId: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY,
secretAccessKey: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_SECRET,
});
const url = await presignGetUrl(s3, {
bucket,
key: path,
expiresInSeconds,
ContentType: contentType,
});
return NextResponse.json({ url });
} catch (err) {
console.error(err);
return err instanceof z.ZodError
? NextResponse.json({ msg: "Invalid input", issues: err.issues }, { status: 400 })
: NextResponse.json({ msg: "Internal server error" }, { status: 500 });
}
}

View file

@ -42,8 +42,8 @@ function Nav({ userImage }: { userImage: string }) {
<div className="hidden md:block">
<div className="ml-10 flex items-baseline space-x-4">
{makeLink("/home", "Home")}
{makeLink("/due-considerations", "Due Considerations")}
{makeLink("/eco-spreadsheet", "Eco Spreadsheet")}
{/* {makeLink("/due-considerations", "Due Considerations")} */}
{/* {makeLink("/eco-spreadsheet", "Eco Spreadsheet")} */}
{makeLink("/help", "Help")}
<div className="flex-grow"></div>
</div>
@ -108,7 +108,7 @@ function Nav({ userImage }: { userImage: string }) {
>
{(ref) => (
<div className="md:hidden" id="mobile-menu">
<div ref={ref} className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<div ref={ref as React.MutableRefObject<HTMLDivElement | null>} className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<a
href="/home"
className="hover:bg-hoverblue text-white block px-3 py-2 rounded-md text-base font-medium"

View file

@ -1,4 +1,4 @@
import { Dialog, Transition } from "@headlessui/react";
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import { Fragment, useState } from "react";
const SelectComparisonModal = ({
@ -38,7 +38,7 @@ const SelectComparisonModal = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-black opacity-30" />
<DialogBackdrop className="fixed inset-0 bg-black/30" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}

View file

@ -1,74 +0,0 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { pgEnum } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
export const reportType: [string, ...string[]] = [
"QUIDOS_PRESITE_NOTE",
"CHARTED_SURVEYOR_REPORT",
"ENERGY_PERFORMANCE_REPORT",
"U_VALUE_CALCULATOR_REPORT",
"OVERWRITING_U_VALUE_DECLARATION_FORM",
"OSMOSIS_CONDITION_PAS_2035_REPORT",
];
const reportTypeEnum = pgEnum("report_type", reportType);
export const companyInfo = pgTable("companyinfo", {
id: uuid("id").primaryKey().defaultRandom(),
address: text("address").notNull(),
tradingName: text("trading_name").notNull(),
postCode: text("post_code").notNull(),
faxNumber: text("fax_number"),
relatedPartyDisclosure: text("related_party_disclosure"),
});
// --- assessorInfo table ---
export const assessorInfo = pgTable("assessorinfo", {
id: uuid("id").primaryKey().defaultRandom(),
accreditationNumber: text("accreditation_number").notNull(),
name: text("name").notNull(),
phoneNumber: text("phone_number"),
emailAddress: text("email_address"),
companyId: uuid("company_id").references(() => companyInfo.id),
});
// --- buildings table ---
export const buildings = pgTable("buildings", {
id: uuid("id").primaryKey().defaultRandom(),
address: text("address").notNull(),
postcode: text("postcode").notNull(),
uprn: text("UPRN").notNull(),
landlordId: text("landlord_id").notNull(),
domnaId: text("domna_id").notNull(),
});
// --- documents table ---
export const documents = pgTable("documents", {
id: uuid("id").primaryKey().defaultRandom(),
authorId: uuid("assessor_id")
.notNull()
.references(() => assessorInfo.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
documentType: reportTypeEnum("document_type").notNull(),
buildingId: uuid("building_id")
.notNull()
.references(() => buildings.id),
targetTable: text("target_table").notNull(),
targetId: uuid("target_id").notNull(),
});
export type Building = InferModel<typeof buildings, "select">;
export type Document = InferModel<typeof documents, "select">;
export type AssessorInfo = InferModel<typeof assessorInfo, "select">;
export type DocumentWithAuthor = Document & {
author: AssessorInfo;
};
export type BuildingWithDocuments = Building & {
documents: DocumentWithAuthor[];
};
export type ReportType = (typeof reportType)[number];

View file

@ -1,22 +0,0 @@
import { pgTable, serial, text, integer } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import {
buildings,
documents,
assessorInfo,
} from "@/app/db/documents_schema/documents";
export const buildingsRelations = relations(buildings, ({ many }) => ({
documents: many(documents),
}));
export const documentsRelations = relations(documents, ({ one }) => ({
building: one(buildings, {
fields: [documents.buildingId],
references: [buildings.id],
}),
author: one(assessorInfo, {
fields: [documents.authorId],
references: [assessorInfo.id],
}),
}));

View file

@ -10,7 +10,7 @@ import {
boolean,
} from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
import { C } from "drizzle-orm/db.d-cf0abe10";
// import { C } from "drizzle-orm/db.d-cf0abe10";
export const solar = pgTable("solar", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),

View file

@ -0,0 +1,58 @@
// app/db/surveyDB/api/insert_data_to_uploaded_files/route.ts
import { NextResponse } from "next/server";
import { z, ZodError } from "zod";
import { insertUploadedFile } from "../../utils/utility"; // ensure path is correct
import { ReportTypeSchema, reportTypeToDbLabel } from "../../schema/documents";
export const runtime = "nodejs";
// Helper: "" or whitespace -> undefined (so optional() can drop it)
const emptyToUndefined = (v: unknown) => {
if (typeof v === "string" && v.trim() === "") return undefined;
return v;
};
const BodySchema = z.object({
s3JsonUri: z.preprocess(
emptyToUndefined,
z.string().url().optional()
),
s3FileUri: z.string().url(),
docType: ReportTypeSchema,
// Required upload timestamp (coerce from ISO string)
s3FileUploadTimestamp: z.coerce.date(),
// Optional JSON timestamp: allow "" -> undefined, then coerce to Date
s3JsonUploadTimestamp: z.preprocess(
emptyToUndefined,
z.coerce.date().optional()
),
uprn: z.string().min(1),
});
export async function POST(req: Request) {
try {
const parsed = BodySchema.parse(await req.json());
const row = await insertUploadedFile({
s3JsonUri: parsed.s3JsonUri, // undefined -> util converts to null
s3FileUri: parsed.s3FileUri,
docType: parsed.docType,
s3FileUploadTimestamp: parsed.s3FileUploadTimestamp,
s3JsonUploadTimestamp: parsed.s3JsonUploadTimestamp, // undefined -> util converts to null
uprn: parsed.uprn,
});
return NextResponse.json(row, { status: 201 });
} catch (e) {
if (e instanceof ZodError) {
return NextResponse.json(
{ error: "Invalid payload", details: e.flatten() },
{ status: 400 }
);
}
console.error(e);
return NextResponse.json(
{ error: "Failed to insert uploaded_file table in surveyDB" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { sendToQueue } from "@/app/utils/sqs";
export async function POST(req: NextRequest) {
try {
const { id } = await req.json();
if (!id) {
return NextResponse.json({ error: "Missing id" }, { status: 400 });
}
const resp = await sendToQueue({ id }, { queueName: "extractor-loader-queue" });
return NextResponse.json(
{ ok: true, messageId: resp.MessageId },
{ status: 200 }
);
} catch (err: any) {
console.error("SQS enqueue failed:", err);
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { listQueues } from "@/app/utils/sqs";
// Handle GET requests
export async function GET() {
try {
const queues = await listQueues(); // optionally pass a prefix
return NextResponse.json({ queues }, { status: 200 });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,58 @@
// app/db/surveyDB/api/insert_data_to_uploaded_files/route.ts
import { NextResponse } from "next/server";
import { z, ZodError } from "zod";
import { insertUploadedFile } from "../../utils/utility"; // ensure path is correct
import { ReportTypeSchema, reportTypeToDbLabel } from "../../schema/documents";
export const runtime = "nodejs";
// Helper: "" or whitespace -> undefined (so optional() can drop it)
const emptyToUndefined = (v: unknown) => {
if (typeof v === "string" && v.trim() === "") return undefined;
return v;
};
const BodySchema = z.object({
s3JsonUri: z.preprocess(
emptyToUndefined,
z.string().url().optional()
),
s3FileUri: z.string().url(),
docType: ReportTypeSchema,
// Required upload timestamp (coerce from ISO string)
s3FileUploadTimestamp: z.coerce.date(),
// Optional JSON timestamp: allow "" -> undefined, then coerce to Date
s3JsonUploadTimestamp: z.preprocess(
emptyToUndefined,
z.coerce.date().optional()
),
uprn: z.string().min(1),
});
export async function POST(req: Request) {
try {
const parsed = BodySchema.parse(await req.json());
const row = await insertUploadedFile({
s3JsonUri: parsed.s3JsonUri, // undefined -> util converts to null
s3FileUri: parsed.s3FileUri,
docType: parsed.docType,
s3FileUploadTimestamp: parsed.s3FileUploadTimestamp,
s3JsonUploadTimestamp: parsed.s3JsonUploadTimestamp, // undefined -> util converts to null
uprn: parsed.uprn,
});
return NextResponse.json(row, { status: 201 });
} catch (e) {
if (e instanceof ZodError) {
return NextResponse.json(
{ error: "Invalid payload", details: e.flatten() },
{ status: 400 }
);
}
console.error(e);
return NextResponse.json(
{ error: "Failed to insert uploaded_file table in surveyDB" },
{ status: 500 }
);
}
}

View file

@ -1,8 +1,7 @@
// db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as documentsSchema from "@/app/db/documents_schema/documents";
import * as relations from "@/app/db/documents_schema/relations";
import * as documentsSchema from "@/app/db/surveyDB/schema/surveyDB";
export const pool = new Pool({
host: process.env.DOCUMENTS_DB_HOST,
@ -19,9 +18,8 @@ export const pool = new Pool({
const schema = {
...documentsSchema,
...relations,
};
export const documentsDB = drizzle(pool, {
export const surveyDB = drizzle(pool, {
schema: schema,
});

View file

@ -0,0 +1,73 @@
// Enum values copied from the backend (Drizzle + Python)
import { z } from "zod";
export const REPORT_TYPES = [
// "quidos_presite_note",
// "charted_surveyor_report",
// "u_value_calculator_report",
// "overwriting_u_value_declaration_form",
"osmosis_condition_pas_2035_report",
// "warm_homes_condition_pas_2035_report",
// "energy_performance_report_with_data",
"energy_performance_report_summary_information",
"lodgement_xml_needed_for_lodgement_to_like_trademark",
"reduce_xml_needed_to_generate_full_sap_xml",
"full_xml_needed_for_co_ordination",
// "floor_plan",
// "occupancy_assessment",
] as const;
export type ReportType = (typeof REPORT_TYPES)[number];
// Map reportType → title for UI
export const documentTypeTitles: Record<ReportType, string> = {
// quidos_presite_note: "RdSAP Summary Report",
// charted_surveyor_report: "Chartered Surveyor Report",
// u_value_calculator_report: "U-Value Calculator Report",
// overwriting_u_value_declaration_form: "Overwriting U-Value Declaration Form",
osmosis_condition_pas_2035_report: "Osmosis Condition Report (PAS 2035)",
// warm_homes_condition_pas_2035_report: "Warm Homes PAS 2035 Report",
// energy_performance_report_with_data: "EPC Report With Data",
energy_performance_report_summary_information: "EPC Summary Report",
lodgement_xml_needed_for_lodgement_to_like_trademark: "LIG XML",
reduce_xml_needed_to_generate_full_sap_xml: "RdSAP XML",
full_xml_needed_for_co_ordination: "Full SAP XML",
// floor_plan: "Floor Plan",
// occupancy_assessment: "Occupancy Assessment",
};
// Map reportType → accepted file extensions
export const documentTypeFileTypes: Record<ReportType, ".pdf" | ".xml" | ".xml,.pdf"> = {
// quidos_presite_note: ".pdf",
// charted_surveyor_report: ".pdf",
// u_value_calculator_report: ".pdf",
// overwriting_u_value_declaration_form: ".pdf",
osmosis_condition_pas_2035_report: ".pdf",
// warm_homes_condition_pas_2035_report: ".pdf",
// energy_performance_report_with_data: ".pdf",
energy_performance_report_summary_information: ".pdf",
lodgement_xml_needed_for_lodgement_to_like_trademark: ".xml",
reduce_xml_needed_to_generate_full_sap_xml: ".xml",
full_xml_needed_for_co_ordination: ".xml",
// floor_plan: ".pdf",
// occupancy_assessment: ".pdf",
};
export const ReportTypeSchema = z.enum(REPORT_TYPES);
// Map UI value -> DB enum NAME
export const reportTypeToDbLabel: Record<ReportType, string> = {
osmosis_condition_pas_2035_report: "ECO_CONDITION_REPORT",
energy_performance_report_summary_information: "ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION",
lodgement_xml_needed_for_lodgement_to_like_trademark: "LIG_XML",
reduce_xml_needed_to_generate_full_sap_xml: "RDSAP_XML",
full_xml_needed_for_co_ordination: "FULLSAP_XML",
};
// Optional reverse map (for reading from API):
export const dbLabelToReportType: Record<string, ReportType> = {
ECO_CONDITION_REPORT: "osmosis_condition_pas_2035_report",
ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION: "energy_performance_report_summary_information",
LIG_XML: "lodgement_xml_needed_for_lodgement_to_like_trademark",
RDSAP_XML: "reduce_xml_needed_to_generate_full_sap_xml",
FULLSAP_XML: "full_xml_needed_for_co_ordination",
};

View file

@ -0,0 +1,31 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { pgEnum } from "drizzle-orm/pg-core";
export const DB_REPORT_TYPES = [
"ECO_CONDITION_REPORT",
"ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION",
"LIG_XML",
"RDSAP_XML",
"FULLSAP_XML",
] as const;
export const docTypeEnum = pgEnum("reporttype", DB_REPORT_TYPES);
export const uploaded_files = pgTable("uploaded_files", {
id: uuid("id").primaryKey().defaultRandom(),
s3JsonUri: text("s3_json_uri"),
s3FileUri: text("s3_file_uri").notNull(),
docType: docTypeEnum("doc_type").notNull(), // enum used here ✅
s3FileUploadTimestamp: timestamp("s3_file_upload_timestamp", { withTimezone: true }).notNull(),
s3JsonUploadTimestamp: timestamp("s3_json_upload_timestamp", { withTimezone: true }),
uprn: text("uprn").notNull(),
});
export type getUploadedFile = typeof uploaded_files.$inferSelect
export type getUploadedFiles = getUploadedFile[];

View file

@ -0,0 +1,31 @@
// insertUploadedFile.ts
import { uploaded_files, docTypeEnum } from "@/app/db/surveyDB/schema/surveyDB";
import { surveyDB } from "../connection";
import type { ReportType, ReportTypeSchema} from "../schema/documents";
import { reportTypeToDbLabel } from "../schema/documents";
type DbDocType = (typeof docTypeEnum.enumValues)[number];
export interface UploadedFileInput {
s3JsonUri?: string; // optional
s3FileUri: string;
docType: ReportType;
s3FileUploadTimestamp: Date;
s3JsonUploadTimestamp?: Date; // optional
uprn: string;
}
export async function insertUploadedFile(data: UploadedFileInput) {
const [newFile] = await surveyDB
.insert(uploaded_files)
.values({
s3JsonUri: data.s3JsonUri ?? null, // Pass null if missing
s3FileUri: data.s3FileUri,
docType: reportTypeToDbLabel[data.docType] as DbDocType, // map UI value -> DB enum NAME
s3FileUploadTimestamp: data.s3FileUploadTimestamp,
s3JsonUploadTimestamp: data.s3JsonUploadTimestamp ?? null, // Pass null if missing
uprn: data.uprn,
})
.returning();
return newFile;
}

View file

@ -1,44 +1,42 @@
"use client";
import { useRouter } from "next/navigation";
import React from "react";
import { TableCell, TableRow } from "@/app/shadcn_components/ui/table";
import {
DocumentWithAuthor,
ReportType,
} from "@/app/db/documents_schema/documents";
import { BrandButton } from "@/app/components/Buttons";
import { MenuButton } from "./MenuButton";
import { useState } from "react";
import { UploadModal } from "./UploadModal";
import { documentTypeTitles, type ReportType } from "@/app/db/surveyDB/schema/documents";
import type { getUploadedFiles, getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
// Descriptions based on the document types
const descriptions: Record<ReportType, string> = {
QUIDOS_PRESITE_NOTE:
"Pre-site note from Quidos, detailing surveyor's findings",
CHARTED_SURVEYOR_REPORT: "Detailed report by a chartered surveyor",
ENERGY_PERFORMANCE_REPORT: "Energy performance breakdown",
U_VALUE_CALCULATOR_REPORT: "Calculated U-values for walls, floors, and roofs",
OVERWRITING_U_VALUE_DECLARATION_FORM: "Signed form for overwriting U-values",
OSMOSIS_CONDITION_PAS_2035_REPORT:
"Osmosis-generated PAS 2035 Condition Report",
type Props = {
reportType: ReportType;
uprn: string;
files: getUploadedFiles;
};
export const DocumentSection = ({
title,
docs,
sectionKey,
documentType,
fileTypes,
}: {
title: string;
docs: DocumentWithAuthor[];
sectionKey: string;
documentType: ReportType;
fileTypes: ".xml,.pdf" | ".xml" | ".pdf";
}) => {
const [showUploadModal, setShowUploadModal] = useState(false);
const [expanded, setExpanded] = useState(false);
const toggle = () => setExpanded((prev) => !prev);
export const DocumentSection: React.FC<Props> = ({ reportType, uprn, files }) => {
const [showUploadModal, setShowUploadModal] = React.useState(false);
const router = useRouter();
const latestFile = React.useMemo<getUploadedFile | null>(() => {
if (!files?.length) return null;
return files.reduce((acc, cur) => {
const accTime = new Date(acc.s3FileUploadTimestamp as any).getTime();
const curTime = new Date(cur.s3FileUploadTimestamp as any).getTime();
return curTime > accTime ? cur : acc;
}, files[0]);
}, [files]);
const formatWhen = (d: string | Date) =>
new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(d));
const title = documentTypeTitles[reportType];
const count = files.length;
return (
<>
@ -48,15 +46,27 @@ export const DocumentSection = ({
</TableCell>
<TableCell className="px-6 py-4 text-sm text-gray-500">
{docs.length > 0 ? (
<button
onClick={toggle}
className="text-brandgold font-medium hover:underline"
>
{expanded ? "Hide Documents" : `View Documents (${docs.length})`}
</button>
{latestFile ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<a
href={latestFile.s3FileUri}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:no-underline"
>
View latest file
</a>
<span className="text-xs text-gray-400">
uploaded {formatWhen(latestFile.s3FileUploadTimestamp)}
</span>
</div>
<span className="text-xs text-gray-500">
{count} file{count !== 1 && "s"} on record
</span>
</div>
) : (
<span className="text-gray-400 italic">No documents available</span>
<span className="text-gray-400">No files uploaded yet</span>
)}
</TableCell>
@ -66,44 +76,17 @@ export const DocumentSection = ({
onClick={() => setShowUploadModal(true)}
backgroundColor="brandblue"
/>
<UploadModal
open={showUploadModal}
onClose={() => setShowUploadModal(false)}
documentType={documentType}
fileTypes={fileTypes}
onClose={() => {
setShowUploadModal(false);
router.refresh();
}}
documentType={reportType}
uprn={uprn}
/>
</TableCell>
</TableRow>
{expanded &&
docs.map((doc) => (
<TableRow key={doc.id}>
<TableCell className="px-6 py-4 text-sm text-gray-800">
{`Uploaded: ${doc.createdAt.toLocaleDateString("en-GB")}`}
<div className="text-xs text-gray-500 mt-1">
{descriptions[doc.documentType] ?? ""}
</div>
</TableCell>
<TableCell className="px-6 py-4 text-sm text-gray-500">
{`Created by: ${
doc.author.emailAddress ?? "No Author Information"
}`}
</TableCell>
<TableCell className="px-6 py-4 text-sm text-right">
<MenuButton
onView={() => {
console.log("View clicked for", doc.id);
}}
onDelete={() => {
console.log("Delete clicked for", doc.id);
}}
/>
</TableCell>
</TableRow>
))}
</>
);
};

View file

@ -1,135 +1,60 @@
"use client";
import React from "react";
import {
Table,
TableBody,
TableRow,
TableCell,
} from "@/app/shadcn_components/ui/table";
import { DocumentWithAuthor } from "@/app/db/documents_schema/documents";
import { Table, TableBody, TableRow, TableCell } from "@/app/shadcn_components/ui/table";
import { DocumentSection } from "./DocumentSection";
import { useMutation } from "@tanstack/react-query";
import { MenuButton } from "./MenuButton";
import {
type ReportType,
REPORT_TYPES,
dbLabelToReportType, // <-- import the map
} from "@/app/db/surveyDB/schema/documents";
import type { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
type Props = {
documents: DocumentWithAuthor[];
// allowedTypes: (typeof DocumentType)[number][]; // Use the union type for allowedTypes as well
uprn: string;
uploadedFilesData: getUploadedFile[];
};
// Fetch the presigned URL from the API
async function generatePresignedUrl(fileKey: string) {
const response = await fetch("/api/energy-assessment-documents", {
method: "POST",
body: JSON.stringify({ fileKey }),
});
export const DocumentsTable: React.FC<Props> = ({ uprn, uploadedFilesData }) => {
const filesByType = React.useMemo(() => {
const map: Partial<Record<ReportType, getUploadedFile[]>> = {};
if (!response.ok) {
throw new Error("Failed to generate presigned URL");
}
for (const file of uploadedFilesData ?? []) {
const uiKey = dbLabelToReportType[file.docType]; // map DB → UI
if (!uiKey) continue; // unknown/legacy type? skip safely
const data = await response.json();
return data.url;
}
export const DocumentsTable: React.FC<Props> = ({
documents,
// allowedTypes,
}) => {
const [expanded, setExpanded] = React.useState(false);
// Mutation to handle the presigned URL generation
const { mutate: fetchPresignedUrl } = useMutation(
// Use the file key as the argument to generate the URL
async (fileKey: string) => await generatePresignedUrl(fileKey),
{
onSuccess: (url) => {
window.open(url, "_blank"); // Open the file in a new tab
},
onError: (error) => {
console.error("Error generating presigned URL:", error);
},
(map[uiKey] ??= []).push(file);
}
);
const handleDownload = () => {
// Generate URL and open in new tab
// fetchPresignedUrl(documentLocation);
console.log("Download button clicked");
};
// newest first within each group
Object.values(map).forEach(arr =>
arr!.sort(
(a, b) =>
new Date(b.s3FileUploadTimestamp as any).getTime() -
new Date(a.s3FileUploadTimestamp as any).getTime()
)
);
const handleUpload = () => {
// Handle the upload logic here
console.log("Upload button clicked");
};
// We split out the various document types. Filter all of the quidos pre-site notes
const quidosPreSite = documents.filter(
(doc) => doc.documentType === "QUIDOS_PRESITE_NOTE"
);
const osmosisConditionReport = documents.filter(
(doc) => doc.documentType === "OSMOSIS_CONDITION_PAS_2035_REPORT"
);
const floors = documents.filter((doc) => doc.documentType === "FLOOR_PLAN");
const occupancy = documents.filter(
(doc) => doc.documentType === "OCCUPANCY_ASSESSMENT"
);
return map;
}, [uploadedFilesData]);
return (
// Quidos Pre-Site Notes Row
<Table className="min-w-full table-fixed divide-y divide-gray-200 shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<TableBody className="bg-white divide-y divide-gray-200">
<DocumentSection
title="RdSAP Summary Report"
docs={quidosPreSite}
sectionKey="rdsap"
documentType="QUIDOS_PRESITE_NOTE"
fileTypes=".pdf"
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
<DocumentSection
title="Condition Report"
docs={osmosisConditionReport}
sectionKey="condition"
documentType="OSMOSIS_CONDITION_PAS_2035_REPORT"
fileTypes=".pdf"
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
<DocumentSection
title="Floor Plan"
docs={floors}
sectionKey="floorplan"
documentType="FLOOR_PLAN"
fileTypes=".pdf"
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
<DocumentSection
title="Occupancy Assessment"
docs={occupancy}
sectionKey="occupancy"
documentType="OCCUPANCY_ASSESSMENT"
fileTypes=".pdf"
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
{REPORT_TYPES.map((reportType) => {
const filesForType = filesByType[reportType] ?? [];
return (
<React.Fragment key={reportType}>
<DocumentSection
reportType={reportType}
uprn={uprn}
files={filesForType} // array of rows
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
</React.Fragment>
);
})}
</TableBody>
</Table>
);

View file

@ -9,54 +9,156 @@ import {
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
import { ReportType } from "@/app/db/documents_schema/documents";
import { Input } from "@/app/shadcn_components/ui/input";
import { useState } from "react";
import { uploadFileToS3 } from "@/app/utils/s3";
import { documentTypeFileTypes, documentTypeTitles, ReportType } from "@/app/db/surveyDB/schema/documents";
type UploadModalProps = {
open: boolean;
onClose: () => void;
documentType: string;
fileTypes: ".xml,.pdf" | ".xml" | ".pdf";
documentType: ReportType; // <- strongly typed
uprn: string;
};
const titles: Record<ReportType, string> = {
QUIDOS_PRESITE_NOTE: "RdSAP Summary Report",
};
// Fetch presigned URL from API
async function generatePresignedUrls({
path,
contentType,
expiresInSeconds,
}: {
path: string;
contentType: string;
expiresInSeconds: number;
}) {
const body = JSON.stringify({ path, expiresInSeconds, contentType });
const res = await fetch("/api/upload/retrofit-energy-assessments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
if (!res.ok) throw new Error("Failed to get presigned URL");
return res.json() as Promise<{ url: string }>;
}
export const UploadModal = ({
open,
onClose,
documentType,
fileTypes = ".xml,.pdf",
}: UploadModalProps) => {
// fetch sqs quess and show it in logs for testing purposes
export async function fetchQueuesAndLog() {
try {
const res = await fetch("/db/surveyDB/api/show_all_sqs_available");
if (!res.ok) throw new Error("Failed to fetch queues");
const data = await res.json();
console.log("✅ Available SQS queues:", data.queues);
} catch (err) {
console.error("❌ Error fetching queues:", err);
}
}
// Decide content-type from file extension
function contentTypeFor(ext: string): string {
const e = ext.toLowerCase();
if (e === "pdf") return "application/pdf";
if (e === "xml") return "application/xml";
return "application/octet-stream";
}
export const UploadModal = ({ open, onClose, documentType, uprn }: UploadModalProps) => {
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [buttonDisabled, setButtonDisabled] = useState(true);
const [submitting, setSubmitting] = useState(false);
const accepted = documentTypeFileTypes[documentType]; // ".pdf" | ".xml" | ".xml,.pdf"
const title = documentTypeTitles[documentType];
function handleInputOnChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files) {
const filesArray = Array.from(e.target.files);
const extensions = filesArray.map((file) =>
file.name.split(".").pop()?.toLowerCase()
);
// The valid extension are defined by filetypes e.g. ".xml,.pdf" so we split on the comma
const validExtensions = fileTypes
.split(",")
.map((ext) => ext.replace(".", ""));
if (!e.target.files?.length) {
setUploadFiles([]);
return;
}
const file = e.target.files[0];
// Check if the files have valid extensions
const isValid = extensions.every((ext) =>
validExtensions.includes(ext || "")
);
// Validate by extension against accepted
const ext = (file.name.split(".").pop() || "").toLowerCase();
const validExtensions = accepted.split(",").map((x) => x.replace(".", ""));
const isValid = validExtensions.includes(ext);
if (isValid) {
setUploadFiles(filesArray);
setButtonDisabled(false);
} else {
setButtonDisabled(true);
if (!isValid) {
setUploadFiles([]);
return;
}
setUploadFiles([file]);
}
async function handleS3Upload() {
if (!uploadFiles.length) return;
setSubmitting(true);
try {
// Timestamp like YYYYMMDD_HHMMSS
const timestamp = new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace("T", "_")
.split(".")[0];
const file = uploadFiles[0];
const ext = (file.name.split(".").pop() || "").toLowerCase();
const ct = contentTypeFor(ext);
const s3Key = `documents/${uprn}/${documentType}/${timestamp}.${ext}`;
// 1) Get presigned URL
const { url } = await generatePresignedUrls({
path: s3Key,
contentType: ct,
expiresInSeconds: 5 * 60,
});
// 2) Upload to S3 via presigned URL
await uploadFileToS3({
presignedUrl: url,
file,
contentType: ct,
});
// 3) Record in DB (store durable HTTPS URL without query params)
const presigned = new URL(url);
const s3FileUri = presigned.origin + presigned.pathname;
const res = await fetch("/db/surveyDB/api/insert_data_to_uploaded_files", {
// If you move your route to the conventional path, change to "/api/uploaded-files"
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
s3FileUri,
docType: documentType, // ReportType value
uprn,
s3FileUploadTimestamp: new Date().toISOString(),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error("DB insert failed:", err);
throw new Error("Failed to insert uploaded file record");
}
} else {
setButtonDisabled(true);
const { id: db_id } = await res.json()
console.log("db_id is ", db_id);
// SQS list
console.log("Sending request to sqs")
// enqueue only the id
await fetch("/db/surveyDB/api/send_to_extractor_loader", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: db_id }), // 👈 only id
});
console.log(`sent request with ${db_id} check with aws sqs queue`);
// Success — close the dialog and let parent refresh UI
onClose();
} catch (err) {
console.error(err);
// You can show a toast here if you have one
} finally {
setSubmitting(false);
}
}
@ -66,15 +168,14 @@ export const UploadModal = ({
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
<DialogDescription>
Upload an <strong>{titles[documentType]}</strong>. Once uploaded,
automated extraction can begin.
Upload a <strong>{title}</strong>. Once uploaded, automated extraction can begin.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
type="file"
accept={fileTypes}
accept={accepted}
multiple={false}
className="cursor-pointer"
onChange={handleInputOnChange}
@ -82,17 +183,11 @@ export const UploadModal = ({
</div>
<DialogFooter>
<Button variant="secondary" onClick={onClose}>
<Button variant="secondary" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
onClick={() => {
console.log("Uploading for", documentType);
onClose();
}}
disabled={buttonDisabled}
>
Upload
<Button onClick={handleS3Upload} disabled={!uploadFiles.length || submitting}>
{submitting ? "Uploading…" : "Upload"}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -1,40 +1,19 @@
import { documentsDB } from "@/app/db/documents_db";
import {
buildings,
DocumentWithAuthor,
BuildingWithDocuments,
} from "@/app/db/documents_schema/documents";
import { getPropertyMeta } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
import { eq } from "drizzle-orm";
import { DocumentsTable } from "./DocumentsTable";
import { surveyDB } from "@/app/db/surveyDB/connection";
import { uploaded_files } from "@/app/db/surveyDB/schema/surveyDB";
import { type getUploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
import { EmptyObject } from "react-hook-form";
async function getDocuments(
uprn: number
): Promise<BuildingWithDocuments | undefined> {
const result = documentsDB.query.buildings.findFirst({
where: eq(buildings.uprn, String(uprn)),
with: {
documents: {
with: {
author: true, // Include author information - there will only be one author per document
},
},
},
): Promise< getUploadedFiles> {
const result = surveyDB.query.uploaded_files.findMany({
where: eq(uploaded_files.uprn, String(uprn)),
});
// If we have no buildings, we return an empty object
if (!result) {
return {
id: "",
address: "",
postcode: "",
uprn: String(uprn),
landlordId: "",
domnaId: "",
documents: [] as DocumentWithAuthor[],
} as BuildingWithDocuments;
}
return result;
}
@ -51,7 +30,7 @@ export default async function DocumentsPage(
}
const propertyMeta = await getPropertyMeta(propertyId);
const documents = await getDocuments(propertyMeta.uprn);
const uploadedFiles = await getDocuments(propertyMeta.uprn);
return (
<>
@ -60,7 +39,10 @@ export default async function DocumentsPage(
Core Survey Documents
</div>
<div className="py-4">
<DocumentsTable documents={documents?.documents ?? []} />
<DocumentsTable
uprn={propertyMeta.uprn.toString()}
uploadedFilesData={uploadedFiles}
/>
</div>
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">

View file

@ -2,7 +2,7 @@ import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { Button } from "@/app/shadcn_components/ui/button";
import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { Float } from "@headlessui-float/react";
// import { Float } from "@headlessui-float/react";
export type Option = { label: string; value: string; disabled?: boolean };

View file

@ -1,6 +1,14 @@
"use client";
import { Dialog, Transition } from "@headlessui/react";
import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from "@headlessui/react";
import { Fragment, useMemo } from "react";
import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
@ -542,7 +550,7 @@ export default function RemoteAssessmentModal({
onClose={() => setIsOpen(false)}
>
<div className="min-h-screen px-4 text-center">
<Transition.Child
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
@ -551,8 +559,8 @@ export default function RemoteAssessmentModal({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<DialogBackdrop className="fixed inset-0 bg-black/25" />
</TransitionChild>
{/* Spacer for centering */}
<span
@ -562,7 +570,7 @@ export default function RemoteAssessmentModal({
&#8203;
</span>
<Transition.Child
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
@ -571,10 +579,10 @@ export default function RemoteAssessmentModal({
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="inline-block w-full max-w-2xl p-6 my-8 overflow-visible text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl">
<Dialog.Title className="text-lg font-medium">
<DialogPanel className="inline-block w-full max-w-2xl p-6 my-8 overflow-visible text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl">
<DialogTitle className="text-lg font-medium">
Remote Assessment Details
</Dialog.Title>
</DialogTitle>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="space-y-6 mt-4">
@ -929,8 +937,8 @@ export default function RemoteAssessmentModal({
)}
</form>
</FormProvider>
</div>
</Transition.Child>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>

View file

@ -1,6 +1,14 @@
"use client";
import { Dialog, Transition } from "@headlessui/react";
import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from "@headlessui/react";
import { Fragment, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { InputFile } from "@/app/portfolio/[slug]/components/InputFile";
@ -427,7 +435,7 @@ export default function UploadCsvModal({
onClose={() => setIsOpen(false)}
>
<div className="min-h-screen px-4 text-center">
<Transition.Child
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
@ -436,15 +444,15 @@ export default function UploadCsvModal({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<DialogBackdrop className="fixed inset-0 bg-black bg-opacity-25" />
</TransitionChild>
<span
className="inline-block h-screen align-middle"
aria-hidden="true"
>
&#8203;
</span>
<Transition.Child
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
@ -454,9 +462,9 @@ export default function UploadCsvModal({
leaveTo="opacity-0 scale-95"
>
<div className="inline-block w-full max-w-2xl p-6 my-8 overflow-visible text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl">
<Dialog.Title className="text-lg font-medium">
<DialogTitle className="text-lg font-medium">
Upload Property Data
</Dialog.Title>
</DialogTitle>
<FormProvider {...form}>
<form
onSubmit={onSubmit}
@ -745,7 +753,7 @@ export default function UploadCsvModal({
</form>
</FormProvider>
</div>
</Transition.Child>
</TransitionChild>
</div>
</Dialog>
</Transition>

View file

@ -14,9 +14,16 @@ const DialogPortal = ({
className,
children,
...props
}: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Portal> & {
className?: string
}) => (
<DialogPrimitive.Portal {...props}>
<div
className={cn(
"fixed inset-0 z-50 flex items-start justify-center sm:items-center",
className
)}
>
{children}
</div>
</DialogPrimitive.Portal>

72
src/app/utils/s3.ts Normal file
View file

@ -0,0 +1,72 @@
// src/utils/s3.ts
import S3 from "aws-sdk/clients/s3";
// Condig to setup a s3 instance
type S3Config = {
region?: string;
accessKeyId?: string;
secretAccessKey?: string;
signatureVersion?: string;
};
export function createS3Client(config?: S3Config) {
return new S3({
region: config?.region ?? process.env.PRESIGN_AWS_REGION,
accessKeyId: config?.accessKeyId ?? process.env.PRESIGN_AWS_ACCESS_KEY,
secretAccessKey: config?.secretAccessKey ?? process.env.PRESIGN_AWS_SECRET_KEY,
signatureVersion: config?.signatureVersion ?? "v4",
});
}
// Get presigned url from s3
export type PresignGetOptions = {
bucket: string;
key: string;
expiresInSeconds?: number; // default 300
ContentType?: string;
};
/** Presign a GET URL using an existing S3 instance (aws-sdk v2). */
export async function presignGetUrl(
s3: S3,
{ bucket, key, expiresInSeconds = 300, ContentType}: PresignGetOptions
): Promise<string> {
return (s3 as any).getSignedUrlPromise("putObject", {
Bucket: bucket,
Key: key,
Expires: expiresInSeconds,
ContentType: ContentType,
});
}
export async function uploadFileToS3({
presignedUrl,
file,
contentType,
}: {
presignedUrl: string;
file: Blob;
contentType: string;
}) {
try {
const response = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": contentType },
});
if (!response.ok) {
console.error("Upload failed response:", response);
throw new Error("Network response was not ok");
}
} catch (error) {
console.error("Upload error:", error);
throw new Error("Upload failed.");
}
console.log("File uploaded successfully");
return { success: true };
}

78
src/app/utils/sqs.ts Normal file
View file

@ -0,0 +1,78 @@
// utils/sqs.ts
import {
SQSClient,
SendMessageCommand,
GetQueueUrlCommand,
ListQueuesCommand,
SendMessageCommandOutput,
} from "@aws-sdk/client-sqs";
// If you prefer explicit creds via env, keep your current config;
// otherwise, this ctor will use the default credential chain (env vars, shared profile, role, etc.)
const sqsClient = new SQSClient({
region: process.env.SQS_AWS_REGION,
credentials: {
accessKeyId: process.env.SQS_AWS_ACCESS_KEY_ID as string,
secretAccessKey: process.env.SQS_AWS_SECRET_ACCESS_KEY as string,
},
});
let cachedQueueUrl: string | null = null;
// Export if you want to reuse elsewhere
export async function getQueueUrl(queueName: string): Promise<string> {
if (cachedQueueUrl) return cachedQueueUrl;
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;
}
type SendOptions = {
queueName?: string; // defaults to env
groupId?: string; // for FIFO queues only
deduplicationId?: string; // for FIFO queues only
delaySeconds?: number; // 0-900
};
/**
* Send a message to SQS. Handles both standard and FIFO queues.
*/
export async function sendToQueue(
messageBody: unknown,
opts: SendOptions = {}
): Promise<SendMessageCommandOutput> {
const queueName = opts.queueName ?? (process.env.AWS_SQS_QUEUE_NAME as string);
if (!queueName) throw new Error("Missing AWS_SQS_QUEUE_NAME or sendToQueue opts.queueName");
const queueUrl = await getQueueUrl(queueName);
const params: any = {
QueueUrl: queueUrl,
MessageBody: JSON.stringify(messageBody),
};
// If it's a FIFO queue (ends with .fifo), include group/dedupe if provided
const isFifo = queueUrl.endsWith(".fifo");
if (isFifo) {
params.MessageGroupId = opts.groupId ?? "default-group";
if (opts.deduplicationId) params.MessageDeduplicationId = opts.deduplicationId;
}
if (typeof opts.delaySeconds === "number") {
params.DelaySeconds = opts.delaySeconds;
}
return sqsClient.send(new SendMessageCommand(params));
}
/**
* List queues in the configured region.
* Optionally filter by name prefix.
*/
export async function listQueues(prefix?: string): Promise<string[]> {
const resp = await sqsClient.send(new ListQueuesCommand(
prefix ? { QueueNamePrefix: prefix } : {}
));
return resp.QueueUrls ?? [];
}