Merge branch 'feature/upload_a_file_to_s3_bucket_via_lambda' of https://github.com/Hestia-Homes/assessment-model into khalim-env-merge

This commit is contained in:
Khalim Conn-Kowlessar 2025-08-05 16:37:12 +01:00
commit 6c08d547b8
33 changed files with 2218 additions and 1499 deletions

42
.devcontainer/Dockerfile Normal file
View 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 /usr/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

View 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": [
]
}
}
}

View file

@ -0,0 +1,19 @@
version: '3.8'
services:
frontend:
user: "${UID}:${GID}"
build:
context: ..
dockerfile: .devcontainer/Dockerfile
command: sleep infinity
ports:
- "3000:50000"
volumes:
- ..:/workspaces/assessment-model
networks:
- frontend-net
networks:
frontend-net:
driver: bridge

View file

@ -0,0 +1 @@
npm install;

108
.env.development Normal file
View file

@ -0,0 +1,108 @@
NEXTAUTH_SECRET=df425f28-06ab-47c2-bb78-7e604387d463
NEXTAUTH_URL=http://localhost
GOOGLE_CLIENT_ID=232063354367-ustovlgtk3cmtvohvd6tdlejnj1qjjj0.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
EPC_AUTH_TOKEN=a2Nvbm5rb3dsZXNzYXJAZ21haWwuY29tOjY5MGJiMWM0NmIyOGI5ZDUxYzAxMzQzYzNiZGNlZGJjZDNmODQwMzA=
AZURE_AD_B2C_TENANT_NAME=DomnaApp
AZURE_AD_B2C_CLIENT_ID=f0a1f977-ddc4-4037-b129-a310008ee934
AZURE_AD_B2C_CLIENT_SECRET=6uh8Q~dmZNqQy3ZxM_Ce33fVSeW24K27R~pYYduD
AZURE_AD_B2C_PRIMARY_USER_FLOW=B2C_1_signupsignin
AZURE_AD_CLIENT_ID=069e75ee-ba54-45ff-ba77-a06f29c0e21c
AZURE_AD_CLIENT_SECRET=x6D8Q~f2roqrnoP1YuomSGN5CvU0HPtIWqqPPaYW
AZURE_AD_TENANT_ID=4a85e8bb-8b7f-4bbd-adc2-1448bb6a9810
DB_HOST=terraform-20230705170609686900000001.cdgzupxvdyp0.eu-west-2.rds.amazonaws.com
DB_PORT=5432
DB_NAME=DevAssessmentModelDB
DB_USERNAME=DevAddessmentModelDB
DB_PASSWORD=!}-A=3D%(2Awy[Qx
URL=http://localhost:3000
PRSIGN_AWS_ACCESS_KEY=AKIAU5A36PPNMR2G7ZQO
PRESIGN_AWS_SECRET_KEY=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
RETOFIT_PLAN_INPUT_BUCKET_NAME=retrofit-plan-inputs-dev
PRESIGN_AWS_REGION=eu-west-2
DUE_CONSIDERATIONS_BUCKET=retrofit-due-considerations-dev
DUE_CONSIDERATIONS_AWS_ACCESS_KEY=AKIAU5A36PPNPNFWLJOY
DUE_CONSIDERATIONS_AWS_SECRET_KEY=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
DUE_CONSIDERATIONS_AWS_REGION=eu-west-2
ECO_SPREADSHEET_BUCKET=retrofit-eco-spreadsheet-dev
ECO_SPREADSHEET_AWS_ACCESS_KEY=AKIAU5A36PPNPTFDQGOJ
ECO_SPREADSHEET_AWS_SECRET_KEY=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
ECO_SPREADSHEET_AWS_REGION=eu-west-2
RETROFIT_ENERGY_ASSESSMENTS_BUCKET=retrofit-energy-assessments-dev
RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY=AKIAU5A36PPNJMZZ3KRW
ENERGY_ASSESSMENTS_AWS_SECRET=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
FASTAPI_API_KEY=4QPwbB6hEdUloDVtbBJCUTfGBdBgWwpeavWQ7t5Z
FASTAPI_API_URL=https://api.dev.hestia.homes
DUE_CONSIDERATIONS_API_URL=https://api.dev.hestia.homes
ECO_SPREADSHEET_API_URL=https://api.dev.hestia.homes
DOCUMENTS_DATABASE_URL=postgresql://postgres:makingwarmhomes@terraform-20250331175522503500000002.cdgzupxvdyp0.eu-west-2.rds.amazonaws.com:5432/surveyDB
DOCUMENTS_DB_HOST=terraform-20250331175522503500000002.cdgzupxvdyp0.eu-west-2.rds.amazonaws.com
DOCUMENTS_DB_PORT=5432
DOCUMENTS_DB_NAME=surveyDB
DOCUMENTS_DB_USERNAME=postgres
DOCUMENTS_DB_PASSWORD=makingwarmhomes

View file

@ -8,6 +8,7 @@ const nextConfig = {
},
],
},
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev'],
};
// use next-axiom for full stack monitoring

3213
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,8 @@
"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",
@ -41,34 +41,35 @@
"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",
"postcss": "^8.5.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"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.2",
"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
View file

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

View file

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

View file

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

View file

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

View file

@ -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({

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -38,11 +38,12 @@ async function getDocuments(
return result;
}
export default async function DocumentsPage({
params,
}: {
params: { slug: string; propertyId: string };
}) {
export default async function DocumentsPage(
props: {
params: Promise<{ slug: string; propertyId: string }>;
}
) {
const params = await props.params;
// Get the property UPRN
const propertyId = params.propertyId;
if (!propertyId || propertyId === "0") {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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