mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #68 from Hestia-Homes/feature/button_and_upload_to_s3
Feature/button and upload to s3
This commit is contained in:
commit
ae25a9da8f
52 changed files with 9324 additions and 5234 deletions
42
.devcontainer/Dockerfile
Normal file
42
.devcontainer/Dockerfile
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
FROM library/python:3.12-bullseye
|
||||
|
||||
ARG USER=vscode
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install system dependencies in a single layer
|
||||
RUN apt update && apt install -y --no-install-recommends \
|
||||
sudo jq vim curl\
|
||||
&& apt autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create the user and grant sudo privileges
|
||||
RUN useradd -m -s /bin/bash ${USER} \
|
||||
&& echo "${USER} ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/${USER} \
|
||||
&& chmod 0440 /etc/sudoers.d/${USER}
|
||||
|
||||
# Install Node.js 22 (from NodeSource)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt install -y nodejs \
|
||||
&& node -v \
|
||||
&& npm -v
|
||||
|
||||
# # Install aws
|
||||
# RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
# RUN unzip awscliv2.zip
|
||||
# RUN ./aws/install
|
||||
|
||||
# # Install terraform
|
||||
# RUN apt-get update && sudo apt-get install -y gnupg software-properties-common
|
||||
# RUN wget -O- https://apt.releases.hashicorp.com/gpg | \
|
||||
# gpg --dearmor | \
|
||||
# sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null
|
||||
# RUN echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
|
||||
# https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
|
||||
# tee /etc/apt/sources.list.d/hashicorp.list
|
||||
# RUN apt update
|
||||
# RUN apt-get install terraform
|
||||
# RUN terraform -install-autocomplete
|
||||
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /workspaces/assessment-model
|
||||
22
.devcontainer/devcontainer.json
Normal file
22
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "assessment-model",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "frontend",
|
||||
"remoteUser": "vscode",
|
||||
"workspaceFolder": "/workspaces/assessment-model",
|
||||
"postStartCommand": "bash .devcontainer/post-install.sh",
|
||||
"forwardPorts": [3000],
|
||||
"mounts": [
|
||||
// Optional, just makes getting from Downloads (local env) easier
|
||||
// "source=${localEnv:HOME},target=/workspaces/home,type=bind"
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"files.defaultWorkspace": "/workspaces/assessment-model"
|
||||
},
|
||||
"extensions": [
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
19
.devcontainer/docker-compose.yml
Normal file
19
.devcontainer/docker-compose.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
user: "${UID}:${GID}"
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
command: sleep infinity
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ..:/workspaces/assessment-model
|
||||
networks:
|
||||
- frontend-net
|
||||
|
||||
networks:
|
||||
frontend-net:
|
||||
driver: bridge
|
||||
1
.devcontainer/post-install.sh
Normal file
1
.devcontainer/post-install.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
npm install;
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -28,6 +28,7 @@ yarn-error.log*
|
|||
# local env files
|
||||
.env*.local
|
||||
cypress.env.json
|
||||
.env*.development
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const nextConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev'],
|
||||
};
|
||||
|
||||
// use next-axiom for full stack monitoring
|
||||
|
|
|
|||
12993
package-lock.json
generated
12993
package-lock.json
generated
File diff suppressed because it is too large
Load diff
34
package.json
34
package.json
|
|
@ -10,12 +10,12 @@
|
|||
"test:e2e:open": "start-server-and-test dev http://localhost:3000 \"cypress open --e2e\"",
|
||||
"test:e2e:run": "cypress run",
|
||||
"migration:generate": "drizzle-kit generate:pg --config=drizzle.config.ts",
|
||||
"migration:push": "node -r esbuild-register src/app/db/migrate.ts",
|
||||
"create_user": "node -r esbuild-register src/app/db/create_user.ts"
|
||||
"migration:push": "tsx src/app/db/migrate.ts",
|
||||
"create_user": "tsx src/app/db/create_user.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui-float/react": "^0.11.2",
|
||||
"@headlessui/react": "^1.7.14",
|
||||
"@aws-sdk/client-sqs": "^3.864.0",
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"@radix-ui/react-navigation-menu": "^1.1.3",
|
||||
"@radix-ui/react-select": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
|
|
@ -34,41 +34,43 @@
|
|||
"@tanstack/react-table": "^8.9.3",
|
||||
"@tremor/react": "^3.16.0",
|
||||
"@types/node": "20.2.3",
|
||||
"@types/react": "18.2.7",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"aws-sdk": "^2.1415.0",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"clsx": "^1.2.1",
|
||||
"drizzle-orm": "^0.27.1",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"esbuild": "^0.25.8",
|
||||
"eslint": "8.41.0",
|
||||
"eslint-config-next": "13.4.3",
|
||||
"lucide-react": "^0.233.0",
|
||||
"next": "13.4.3",
|
||||
"next": "^15.4.2",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-axiom": "^1.9.2",
|
||||
"next-themes": "^0.3.0",
|
||||
"pg": "^8.11.1",
|
||||
"postcss": "8.4.23",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "5.0.4",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/cypress": "^9.0.0",
|
||||
"@testing-library/cypress": "^10.0.3",
|
||||
"@types/pg": "^8.10.2",
|
||||
"cypress": "^12.17.1",
|
||||
"cypress": "^14.5.3",
|
||||
"cypress-social-logins": "^1.14.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"drizzle-kit": "^0.19.3",
|
||||
"start-server-and-test": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
run_local.sh
Normal file
1
run_local.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
npm run dev
|
||||
|
|
@ -9,10 +9,8 @@ const PermissionsBodySchema = z.object({
|
|||
action: z.enum(["delete", "update"]),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { portfolioId: string } }
|
||||
) {
|
||||
export async function POST(request: NextRequest, props: { params: Promise<{ portfolioId: string }> }) {
|
||||
const params = await props.params;
|
||||
// This endpoint lives at portfolio/{portfolioId}/permissions and will return the permissions level for a given portfolio
|
||||
// Call this endpoint with a) userId, b) portfolioId, c) an action and this api will tell you if that person can do that thing
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,8 @@ const UpdateBodySchema = z.object({
|
|||
status: z.optional(z.string()),
|
||||
});
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { portfolioId: string } }
|
||||
) {
|
||||
export async function PUT(request: NextRequest, props: { params: Promise<{ portfolioId: string }> }) {
|
||||
const params = await props.params;
|
||||
const body = await request.json();
|
||||
let validatedBody;
|
||||
|
||||
|
|
@ -44,7 +42,7 @@ export async function PUT(
|
|||
const budget = validatedBody.budget;
|
||||
const goal = validatedBody.goal;
|
||||
const status = validatedBody.status;
|
||||
|
||||
|
||||
|
||||
await db
|
||||
.update(portfolio)
|
||||
|
|
@ -57,10 +55,8 @@ export async function PUT(
|
|||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { portfolioId: string } }
|
||||
) {
|
||||
export async function DELETE(request: NextRequest, props: { params: Promise<{ portfolioId: string }> }) {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const portfolioId = params.portfolioId;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@ import { DataItem, ChartData } from "@/app/portfolio/[slug]/utils";
|
|||
import { eq } from "drizzle-orm";
|
||||
import { scenario } from "@/app/db/schema/recommendations";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { scenarioId: string } }
|
||||
) {
|
||||
export async function GET(request: NextRequest, props: { params: Promise<{ scenarioId: string }> }) {
|
||||
const params = await props.params;
|
||||
const scenarioId = params.scenarioId;
|
||||
|
||||
const data = await db
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ import { serializeBigInt } from "@/app/utils";
|
|||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { propertyId: string } }
|
||||
) {
|
||||
export async function GET(request: NextRequest, props: { params: Promise<{ propertyId: string }> }) {
|
||||
const params = await props.params;
|
||||
const propertyId = params.propertyId;
|
||||
|
||||
const propertyMeta = await db.query.property.findFirst({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
48
src/app/api/upload/retrofit-energy-assessments/route.ts
Normal file
48
src/app/api/upload/retrofit-energy-assessments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
73
src/app/db/surveyDB/schema/documents.ts
Normal file
73
src/app/db/surveyDB/schema/documents.ts
Normal 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",
|
||||
};
|
||||
26
src/app/db/surveyDB/schema/surveyDB.ts
Normal file
26
src/app/db/surveyDB/schema/surveyDB.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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(),
|
||||
});
|
||||
30
src/app/db/surveyDB/utils/utility.ts
Normal file
30
src/app/db/surveyDB/utils/utility.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// insertUploadedFile.ts
|
||||
import { uploaded_files } from "@/app/db/surveyDB/schema/surveyDB";
|
||||
import { surveyDB } from "../connection";
|
||||
import type { ReportType, ReportTypeSchema} from "../schema/documents";
|
||||
import { reportTypeToDbLabel } from "../schema/documents";
|
||||
|
||||
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], // map UI value -> DB enum NAME
|
||||
s3FileUploadTimestamp: data.s3FileUploadTimestamp,
|
||||
s3JsonUploadTimestamp: data.s3JsonUploadTimestamp ?? null, // Pass null if missing
|
||||
uprn: data.uprn,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newFile;
|
||||
}
|
||||
|
|
@ -2,90 +2,72 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 🌞 Light Theme (raw HSL values) */
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 100% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 215 20.2% 65.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
/* 🌚 Dark Theme (raw HSL values) */
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
}
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 216 34% 17%;
|
||||
|
||||
--destructive: 0 100% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 215 20.2% 65.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 216 34% 17%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
@ -98,7 +80,30 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
/* These styles are for the loading spinner on the due considerations page*/
|
||||
/* 🎨 Tailwind-aware theme tokens */
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-ring: hsl(var(--ring));
|
||||
}
|
||||
|
||||
/* 🔄 Spinner animation */
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ import EmailSignInButton from "./components/signin/CredentialsButton";
|
|||
import { redirect } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { error?: string };
|
||||
}) {
|
||||
export default async function Home(
|
||||
props: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}
|
||||
) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await getServerSession(AuthOptions);
|
||||
|
||||
if (session?.user) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { Toolbar } from "@/app/components/portfolio/Toolbar";
|
||||
import { getPortfolio, getPortfolioScenarios } from "../utils";
|
||||
|
||||
export default async function PortfolioLayout({
|
||||
children, // will be a page or nested layout
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
export default async function PortfolioLayout(
|
||||
props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
const {
|
||||
// will be a page or nested layout
|
||||
children
|
||||
} = props;
|
||||
|
||||
const portfolioId = params.slug;
|
||||
const { name: portfolioName } = await getPortfolio(portfolioId);
|
||||
// We retrieve the scenarios associated with the portfolio
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable";
|
|||
import { getPortfolioMeasures } from "../../utils";
|
||||
import { portfolioPlanColumns } from "@/app/components/portfolio/measures/PlanTableColumns";
|
||||
|
||||
export default async function PortfolioPlan({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
export default async function PortfolioPlan(
|
||||
props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const portfolioId = params.slug;
|
||||
const portfolioMeasures = await getPortfolioMeasures(portfolioId);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ function EmptyPropertyState() {
|
|||
);
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined | number };
|
||||
}) {
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined | number }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
// This page is served from the server so we can make calls to the database
|
||||
|
||||
const portfolioId = params.slug;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { getPortfolioSettings } from "../../utils";
|
||||
import PortfolioSettings from "./PortfolioSettings";
|
||||
|
||||
export default async function PortfolioSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
export default async function PortfolioSettingsPage(
|
||||
props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const portfolioId = params.slug;
|
||||
const portfolioSettingsData = await getPortfolioSettings(portfolioId);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import {
|
|||
getOverviewPortfolioData,
|
||||
} from "../../utils";
|
||||
|
||||
export default async function PortfolioSummary({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
export default async function PortfolioSummary(
|
||||
props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const portfolioId = params.slug;
|
||||
const data = await getOverviewPortfolioData(portfolioId);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,44 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } 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";
|
||||
|
||||
// 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; // <- the only type selector needed
|
||||
uprn: string;
|
||||
};
|
||||
|
||||
export const DocumentSection = ({
|
||||
title,
|
||||
docs,
|
||||
sectionKey,
|
||||
documentType,
|
||||
fileTypes,
|
||||
}: {
|
||||
title: string;
|
||||
docs: DocumentWithAuthor[];
|
||||
sectionKey: string;
|
||||
documentType: ReportType;
|
||||
fileTypes: ".xml,.pdf" | ".xml" | ".pdf";
|
||||
}) => {
|
||||
export const DocumentSection: React.FC<Props> = ({ reportType, uprn }) => {
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const toggle = () => setExpanded((prev) => !prev);
|
||||
|
||||
const title = documentTypeTitles[reportType];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -47,18 +23,7 @@ export const DocumentSection = ({
|
|||
{title}
|
||||
</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>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">No documents available</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-6 py-4 text-sm text-gray-500" />
|
||||
|
||||
<TableCell className="px-6 py-4 text-sm text-right w-1/6">
|
||||
<BrandButton
|
||||
|
|
@ -70,40 +35,11 @@ export const DocumentSection = ({
|
|||
<UploadModal
|
||||
open={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
documentType={documentType}
|
||||
fileTypes={fileTypes}
|
||||
documentType={reportType} // <- strong 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,136 +1,27 @@
|
|||
"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 { type ReportType, REPORT_TYPES, documentTypeFileTypes, documentTypeTitles } from "@/app/db/surveyDB/schema/documents";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { MenuButton } from "./MenuButton";
|
||||
|
||||
type Props = {
|
||||
documents: DocumentWithAuthor[];
|
||||
// allowedTypes: (typeof DocumentType)[number][]; // Use the union type for allowedTypes as well
|
||||
};
|
||||
|
||||
// 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 }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate presigned URL");
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const handleDownload = () => {
|
||||
// Generate URL and open in new tab
|
||||
// fetchPresignedUrl(documentLocation);
|
||||
console.log("Download button clicked");
|
||||
};
|
||||
|
||||
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"
|
||||
);
|
||||
type Props = { uprn: string };
|
||||
|
||||
export const DocumentsTable: React.FC<Props> = ({ uprn }) => {
|
||||
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((rt) => (
|
||||
<React.Fragment key={rt}>
|
||||
<DocumentSection
|
||||
reportType={rt as ReportType}
|
||||
uprn={uprn}
|
||||
/>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={3} className="h-3 p-0" />
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,48 +1,14 @@
|
|||
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";
|
||||
|
||||
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
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
export default async function DocumentsPage(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default async function DocumentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
) {
|
||||
const params = await props.params;
|
||||
// Get the property UPRN
|
||||
const propertyId = params.propertyId;
|
||||
if (!propertyId || propertyId === "0") {
|
||||
|
|
@ -50,7 +16,6 @@ export default async function DocumentsPage({
|
|||
}
|
||||
|
||||
const propertyMeta = await getPropertyMeta(propertyId);
|
||||
const documents = await getDocuments(propertyMeta.uprn);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -59,7 +24,9 @@ export default async function DocumentsPage({
|
|||
Core Survey Documents
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<DocumentsTable documents={documents?.documents ?? []} />
|
||||
<DocumentsTable
|
||||
uprn={propertyMeta.uprn.toString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
|
||||
|
|
|
|||
|
|
@ -63,11 +63,12 @@ const InfoCard: React.FC<InfoCardProps> = ({ title, value, unit }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default async function EnergyAssessmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
export default async function EnergyAssessmentsPage(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const ea = await getEnergyAssessment(propertyMeta.uprn);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,19 @@ function EstimatedDataNotification() {
|
|||
);
|
||||
}
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children, // will be a page or nested layout
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
export default async function DashboardLayout(
|
||||
props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
const {
|
||||
// will be a page or nested layout
|
||||
children
|
||||
} = props;
|
||||
|
||||
const propertyId = params.propertyId ?? "";
|
||||
const portfolioId = params.slug ?? "";
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ import { getPropertyMeta } from "./utils";
|
|||
|
||||
export const revalidate = 1;
|
||||
|
||||
export default async function BuildingPassportHome({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
export default async function BuildingPassportHome(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
// This is a server component and because we make the exact same request in the layout,
|
||||
// the response is cached so we just gain access to the data
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import RecommendationContainer from "@/app/components/building-passport/RecommendationContainer";
|
||||
import { getPropertyMeta, getRecommendations, getPlanMeta } from "../../utils";
|
||||
|
||||
export default async function Recommendations({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; propertyId: string; planId: string };
|
||||
}) {
|
||||
export default async function Recommendations(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string; planId: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const recommendations = await getRecommendations(params.planId);
|
||||
const planMeta = await getPlanMeta(params.planId);
|
||||
|
|
|
|||
|
|
@ -61,11 +61,12 @@ function PlanCard({
|
|||
);
|
||||
}
|
||||
|
||||
export default async function RecommendationPlans({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
export default async function RecommendationPlans(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const plans = await getPlans(params.propertyId);
|
||||
|
||||
|
|
|
|||
|
|
@ -128,11 +128,12 @@ const formatDate = (dateString: Date) => {
|
|||
});
|
||||
};
|
||||
|
||||
export default async function PreAssessmentReport({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
export default async function PreAssessmentReport(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const conditionReportData = await getConditionReport(params.propertyId);
|
||||
const propertyDetailsSpatial = await getSpatialData(propertyMeta.uprn);
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@ import FeatureTable from "@/app/components/building-passport/FeatureTable";
|
|||
import { roofSegmentsColumns } from "./roof-segments-table";
|
||||
import { formatNumber } from "@/app/utils";
|
||||
|
||||
export default async function SolarAnalysisPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
export default async function SolarAnalysisPage(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const solarData = await getSolarData(Number(propertyMeta.uprn));
|
||||
// If there's no solar data, we cannot display the page
|
||||
|
|
|
|||
|
|
@ -701,7 +701,7 @@ export default function RemoteAssessmentModal({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* ✅ Budget shows for ALL goals but is only mandatory when goal != Increasing EPC */}
|
||||
{/* ✅ Budget shows for all goals but is only mandatory when goal != Increasing EPC */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="budget"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, use } from "react";
|
||||
|
||||
export default function LoadingPage({ params }: { params: { slug: string } }) {
|
||||
export default function LoadingPage(props: { params: Promise<{ slug: string }> }) {
|
||||
const params = use(props.params);
|
||||
const portfolioId = params.slug;
|
||||
const router = useRouter();
|
||||
const [countdown, setCountdown] = useState(10); // Initialize countdown state to 10 seconds
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import BackToPortfolio from "@/app/components/portfolio/BackToPortfolio";
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { slug: string; lmkKey: string };
|
||||
}) {
|
||||
export default async function Layout(
|
||||
props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ slug: string; lmkKey: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
const {
|
||||
children
|
||||
} = props;
|
||||
|
||||
const portfolioId = params.slug;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, use } from "react";
|
||||
import { PencilSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { SearchData, EpcRating, EpcKey } from "@/types/epc";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -73,13 +73,14 @@ const partConfig: PartConfig = [
|
|||
},
|
||||
];
|
||||
|
||||
export default function PropertyPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { slug: string; lmkKey: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
export default function PropertyPage(
|
||||
props: {
|
||||
params: Promise<{ slug: string; lmkKey: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
) {
|
||||
const searchParams = use(props.searchParams);
|
||||
const params = use(props.params);
|
||||
const router = useRouter();
|
||||
|
||||
const portfolioId = params.slug;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { EpcRating, SearchData } from "@/types/epc";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { fetchData } from "../utils";
|
||||
import { useState } from "react";
|
||||
import { useState, use } from "react";
|
||||
import { PencilSquareIcon } from "@heroicons/react/24/outline";
|
||||
import PlanPart from "@/app/components/plan/PlanPart";
|
||||
import EditEpctargetModal from "@/app/components/property/EditEpcTargetModal";
|
||||
|
|
@ -13,13 +13,14 @@ import BudgetModal from "@/app/components/plan/BudgetModal";
|
|||
import { formatNumber, roundToDecimalPlaces } from "@/app/utils";
|
||||
import { Part } from "@/types/parts";
|
||||
|
||||
export default function Plan({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { slug: string; lmkKey: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
export default function Plan(
|
||||
props: {
|
||||
params: Promise<{ slug: string; lmkKey: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
) {
|
||||
const searchParams = use(props.searchParams);
|
||||
const params = use(props.params);
|
||||
const router = useRouter();
|
||||
|
||||
const portfolioId = params.slug;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import BackToPortfolio from "@/app/components/portfolio/BackToPortfolio";
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { slug: string; lmkKey: string };
|
||||
}) {
|
||||
export default async function Layout(
|
||||
props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ slug: string; lmkKey: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
const {
|
||||
children
|
||||
} = props;
|
||||
|
||||
const portfolioId = params.slug;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, use } from "react";
|
||||
import SearchPostcodeButton from "../../../components/search/SearchPostcodeButton";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SearchData, SearchResult } from "@/types/epc";
|
||||
|
|
@ -13,7 +13,8 @@ const defaultToggleClass =
|
|||
const toggledButtonClass =
|
||||
"text-white mb-1 block max-w-sm rounded-lg border border-gray-200 bg-brandblue p-6 shadow hover:bg-hoverblue dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700";
|
||||
|
||||
export default function Search({ params }: { params: { slug: string } }) {
|
||||
export default function Search(props: { params: Promise<{ slug: string }> }) {
|
||||
const params = use(props.params);
|
||||
const [postcode, setPostcode] = useState("");
|
||||
const [buttonDisabled, setButtonDisabled] = useState(true);
|
||||
const [data, setData] = useState<null | SearchData>(null);
|
||||
|
|
|
|||
72
src/app/utils/s3.ts
Normal file
72
src/app/utils/s3.ts
Normal 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
78
src/app/utils/sqs.ts
Normal 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 ?? [];
|
||||
}
|
||||
|
|
@ -268,7 +268,6 @@ module.exports = {
|
|||
});
|
||||
});
|
||||
},
|
||||
require("@headlessui/tailwindcss"),
|
||||
require("@tailwindcss/forms"),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue