diff --git a/README.md b/README.md index b58ac46f..e720d029 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next ## Getting Started +When first getting set up you'll firstly want to install the existing dependencies. To do this, simply run + +```bash +npm install +# or +yarn install +``` + First, run the development server: ```bash diff --git a/public/.well-known/microsoft-identity-association.json b/public/.well-known/microsoft-identity-association.json new file mode 100644 index 00000000..975f09e1 --- /dev/null +++ b/public/.well-known/microsoft-identity-association.json @@ -0,0 +1,7 @@ +{ + "associatedApplications": [ + { + "applicationId": "069e75ee-ba54-45ff-ba77-a06f29c0e21c" + } + ] +} \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index bd4ae388..8cc60197 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,15 +1,18 @@ import NextAuth, { NextAuthOptions } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; -import AzureADProvider from "next-auth/providers/azure-ad"; +import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c"; +import CredentialsProvider from "next-auth/providers/credentials"; + import { db } from "@/app/db/db"; import { user as userTable, User } from "@/app/db/schema/users"; import { eq } from "drizzle-orm"; const { GOOGLE_CLIENT_ID = "", GOOGLE_CLIENT_SECRET = "" } = process.env; const { - AZURE_AD_CLIENT_ID = "", - AZURE_AD_CLIENT_SECRET = "", - AZURE_AD_TENANT_ID = "", + AZURE_AD_B2C_TENANT_NAME = "", + AZURE_AD_B2C_CLIENT_ID = "", + AZURE_AD_B2C_CLIENT_SECRET = "", + AZURE_AD_B2C_PRIMARY_USER_FLOW = "", } = process.env; type OauthProvider = "google"; @@ -32,10 +35,50 @@ export const AuthOptions: NextAuthOptions = { }, }, }), - AzureADProvider({ - clientId: AZURE_AD_CLIENT_ID, - clientSecret: AZURE_AD_CLIENT_SECRET, - tenantId: AZURE_AD_TENANT_ID, + AzureADB2CProvider({ + tenantId: AZURE_AD_B2C_TENANT_NAME, + clientId: AZURE_AD_B2C_CLIENT_ID, + clientSecret: AZURE_AD_B2C_CLIENT_SECRET, + primaryUserFlow: AZURE_AD_B2C_PRIMARY_USER_FLOW, + authorization: { + params: { + scope: "openid profile offline_access", + prompt: "login", + }, + }, + }), + CredentialsProvider({ + name: "Email Login", + credentials: { + email: { + label: "Email", + type: "email", + }, + }, + async authorize(credentials, req) { + if (!credentials || !credentials.email) { + throw new Error("Email is required"); + } + + const { email } = credentials; + + // Query the database to find the user by email + const dbUser = await db + .select() + .from(userTable) + .where(eq(userTable.email, email)); + + // If the email exists, return the user object (no password check) + if (dbUser.length === 1) { + return { + id: dbUser[0].id.toString(), // Convert bigint to string to avoid serialization issues + email: dbUser[0].email, + dbId: dbUser[0].id.toString(), // Ensure dbId is added and is a string + }; + } + + return null; + }, }), ], pages: { diff --git a/src/app/api/energy-assessment-documents/route.ts b/src/app/api/energy-assessment-documents/route.ts new file mode 100644 index 00000000..86512b72 --- /dev/null +++ b/src/app/api/energy-assessment-documents/route.ts @@ -0,0 +1,81 @@ +// pages/api/get-presigned-url.ts +import S3 from "aws-sdk/clients/s3"; +import STS from "aws-sdk/clients/sts"; // Import STS for temporary credentials +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +// Validate the input +const PresignedUrlBodySchema = z.object({ + fileKey: z.string(), +}); + +// Function to get temporary credentials using GetSessionToken +async function getTemporaryCredentials() { + const sts = new STS({ + accessKeyId: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY, // Your permanent access key + secretAccessKey: process.env.ENERGY_ASSESSMENTS_AWS_SECRET, // Your permanent secret access key + region: process.env.PRESIGN_AWS_REGION, + }); + + try { + // Request temporary credentials with GetSessionToken + const data = await sts.getSessionToken({ DurationSeconds: 900 }).promise(); // Token valid for 15 minutes + + // Check if credentials are present + if (!data.Credentials) { + throw new Error("Failed to retrieve temporary credentials"); + } + + return data.Credentials; + } catch (error) { + console.error("Error fetching temporary credentials:", error); + throw error; + } +} + +// API handler +export async function POST(request: NextRequest) { + const body = await request.json(); + let validatedBody; + + try { + validatedBody = PresignedUrlBodySchema.parse(body); + } catch (error) { + console.error("Invalid input: ", error); + return new NextResponse(JSON.stringify({ msg: "Invalid input" }), { + status: 400, + }); + } + + try { + // Get temporary credentials using GetSessionToken + const credentials = await getTemporaryCredentials(); + + // Initialize S3 with temporary credentials + const s3 = new S3({ + signatureVersion: "v4", + region: process.env.PRESIGN_AWS_REGION, + accessKeyId: credentials.AccessKeyId, + secretAccessKey: credentials.SecretAccessKey, + sessionToken: credentials.SessionToken, // Include session token + }); + + const { fileKey } = validatedBody; + + // Generate presigned URL valid for 5 minutes + const preSignedUrl = await s3.getSignedUrl("getObject", { + Bucket: process.env.RETROFIT_ENERGY_ASSESSMENTS_BUCKET, + Key: fileKey, + Expires: 5 * 60, // URL expiration in seconds + }); + + return new NextResponse(JSON.stringify({ url: preSignedUrl }), { + status: 200, + }); + } catch (error) { + console.error("Error generating presigned URL:", error); + return new NextResponse(JSON.stringify({ msg: "Internal server error" }), { + status: 500, + }); + } +} diff --git a/src/app/beta/page.tsx b/src/app/beta/page.tsx index ed4ef26e..14afc451 100644 --- a/src/app/beta/page.tsx +++ b/src/app/beta/page.tsx @@ -1,3 +1,3 @@ export default function Beta() { - return
This application is not ready for general usage
; + return
You do not have access to this application currently
; } diff --git a/src/app/components/Buttons.tsx b/src/app/components/Buttons.tsx index 7da4ab2e..9d2d87d7 100644 --- a/src/app/components/Buttons.tsx +++ b/src/app/components/Buttons.tsx @@ -18,3 +18,31 @@ export function TanButton({ ); } + +export function BrandButton({ + label, + onClick, + backgroundColor, +}: { + label: string; + onClick: Dispatch>; + backgroundColor: "brandblue" | "brandgold"; // Restrict backgroundColor to these two options +}) { + // Dictionary to map background colors to hover colors + const hoverColors = { + brandblue: "hover:bg-hoverblue", + brandgold: "hover:bg-hovergold", + }; + + return ( + + ); +} diff --git a/src/app/components/StatusBadge.tsx b/src/app/components/StatusBadge.tsx index b9aae3f9..de90e3a6 100644 --- a/src/app/components/StatusBadge.tsx +++ b/src/app/components/StatusBadge.tsx @@ -20,7 +20,11 @@ export default function StatusBadge({ return ( - {statusConfig.text} + + {statusConfig.text} +
@@ -60,6 +64,12 @@ const statusColor: { hoverText: "This portfolio is currently in the assessment stage", propertyHoverText: "This property is currently in the assessment stage", }, + survey: { + class: "bg-brandblue hover:bg-hoverblue", + text: "survey", + hoverText: "This portfolio is currently in the survey stage", + propertyHoverText: "This property is currently in the survey stage", + }, tendering: { class: "bg-emerald-500 hover:bg-emerald-500", text: "Tendering", diff --git a/src/app/components/building-passport/EpcCard.tsx b/src/app/components/building-passport/EpcCard.tsx index 8b9ca1fe..7f4df5c9 100644 --- a/src/app/components/building-passport/EpcCard.tsx +++ b/src/app/components/building-passport/EpcCard.tsx @@ -6,12 +6,14 @@ export default function EpcCard({ expected = false, kwh = null, carbon = null, + sap = null, }: { epcRating: string; fullMargin: boolean; expected?: boolean; kwh?: number | null; carbon?: number | null; + sap?: string | null; }) { let marginClass = ""; if (fullMargin) { @@ -30,28 +32,35 @@ export default function EpcCard({ return (
{title}
-
{epcRating}
+ + {/* EPC Rating and SAP Rating */} +
+ {/* EPC Rating */} +
{epcRating}
+ + {/* SAP Rating (only if sap is provided) */} + {sap !== null && ( +
{sap}
+ )} +
{(kwh || carbon) && ( {kwh && ( - {" "} - {/* Added vertical padding to each row */} )} {carbon && ( - {" "} )} diff --git a/src/app/components/building-passport/RecommendationCard.tsx b/src/app/components/building-passport/RecommendationCard.tsx index cace8863..f53826c2 100644 --- a/src/app/components/building-passport/RecommendationCard.tsx +++ b/src/app/components/building-passport/RecommendationCard.tsx @@ -17,6 +17,7 @@ const alreadyInstalledStyling = const TitleMap = { mechanical_ventilation: "Mechanical Ventilation", + trickle_vents: "Trickle Vents", sealing_open_fireplace: "Sealing Open Fireplace", low_energy_lighting: "Low Energy Lighting", // Walls @@ -33,6 +34,7 @@ const TitleMap = { exposed_floor_insulation: "Exposed Floor Insulation", // Windows windows_glazing: "Window Glazing", + mixed_glazing: "Mixed - Secondary and Double Glazing", // Solar pv solar_pv: "Solar Photovoltaic Panels System", // Heating @@ -47,6 +49,8 @@ const TitleMap = { roof_insulation: "Roof Insulation", // Cylinder thermostat cylinder_thermostat: "Cylinder Thermostat", + // Draught proofing + draught_proofing: "Draught Proofing", }; type RecommendationCardProps = { diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index 951e6af4..50cf27b5 100644 --- a/src/app/components/building-passport/RecommendationContainer.tsx +++ b/src/app/components/building-passport/RecommendationContainer.tsx @@ -127,6 +127,21 @@ export default function RecommendationContainer({ (rec: Recommendation) => rec.default ) || emptyImpactState; + const defaultTrickleVentsRecommendations = + categorizedRecommendations.trickle_vents?.find( + (rec: Recommendation) => rec.default + ) || emptyImpactState; + + const defaultMixedGlazingRecommendations = + categorizedRecommendations.mixed_glazing?.find( + (rec: Recommendation) => rec.default + ) || emptyImpactState; + + const defaultDraughtProofingRecommendations = + categorizedRecommendations.draught_proofing?.find( + (rec: Recommendation) => rec.default + ) || emptyImpactState; + const [costMap, setCostMap] = useState({ wall_insulation: defaultWallsRecommendations.estimatedCost || 0, floor_insulation: defaultFloorRecommendations.estimatedCost || 0, @@ -145,6 +160,9 @@ export default function RecommendationContainer({ defaultSecondaryHeatingRecommendations.estimatedCost || 0, cylinder_thermostat: defaultCylinderThermostatRecommendations.estimatedCost || 0, + trickle_vents: defaultTrickleVentsRecommendations.estimatedCost || 0, + mixed_glazing: defaultMixedGlazingRecommendations.estimatedCost || 0, + draught_proofing: defaultDraughtProofingRecommendations.estimatedCost || 0, }); const [sapMap, setSapMap] = useState({ @@ -163,6 +181,9 @@ export default function RecommendationContainer({ secondary_heating: defaultSecondaryHeatingRecommendations.sapPoints || 0, cylinder_thermostat: defaultCylinderThermostatRecommendations.sapPoints || 0, + trickle_vents: defaultTrickleVentsRecommendations.sapPoints || 0, + mixed_glazing: defaultMixedGlazingRecommendations.sapPoints || 0, + draught_proofing: defaultDraughtProofingRecommendations.sapPoints || 0, }); const [labourDaysMap, setLabourDaysMap] = useState({ @@ -181,6 +202,9 @@ export default function RecommendationContainer({ secondary_heating: defaultSecondaryHeatingRecommendations.labourDays || 0, cylinder_thermostat: defaultCylinderThermostatRecommendations.labourDays || 0, + trickle_vents: defaultTrickleVentsRecommendations.labourDays || 0, + mixed_glazing: defaultMixedGlazingRecommendations.labourDays || 0, + draught_proofing: defaultDraughtProofingRecommendations.labourDays || 0, }); const [co2SavingsMap, setCo2SavingsMap] = useState({ @@ -204,6 +228,10 @@ export default function RecommendationContainer({ defaultSecondaryHeatingRecommendations.co2EquivalentSavings || 0, cylinder_thermostat: defaultCylinderThermostatRecommendations.co2EquivalentSavings || 0, + trickle_vents: defaultTrickleVentsRecommendations.co2EquivalentSavings || 0, + mixed_glazing: defaultMixedGlazingRecommendations.co2EquivalentSavings || 0, + draught_proofing: + defaultDraughtProofingRecommendations.co2EquivalentSavings || 0, }); const [energyCostSavingsMap, setEnergyCostSavingsMap] = @@ -228,6 +256,10 @@ export default function RecommendationContainer({ defaultSecondaryHeatingRecommendations.energyCostSavings || 0, cylinder_thermostat: defaultCylinderThermostatRecommendations.energyCostSavings || 0, + trickle_vents: defaultTrickleVentsRecommendations.energyCostSavings || 0, + mixed_glazing: defaultMixedGlazingRecommendations.energyCostSavings || 0, + draught_proofing: + defaultDraughtProofingRecommendations.energyCostSavings || 0, }); const [kwhSavingsMap, setKwhSavingsMap] = useState({ @@ -246,6 +278,9 @@ export default function RecommendationContainer({ secondary_heating: defaultSecondaryHeatingRecommendations.kwhSavings || 0, cylinder_thermostat: defaultCylinderThermostatRecommendations.kwhSavings || 0, + trickle_vents: defaultTrickleVentsRecommendations.kwhSavings || 0, + mixed_glazing: defaultMixedGlazingRecommendations.kwhSavings || 0, + draught_proofing: defaultDraughtProofingRecommendations.kwhSavings || 0, }); const [totalEstimatedCost, setTotalEstimatedCost] = useState( diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 8f57d216..58866edf 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -39,6 +39,16 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) { ); + const energyAssessmentsReportButton = ( + + + Energy Assessment + + ); + const solarAnalysisButton = ( - - Plan optimiser - */} + {energyAssessmentsReportButton} void }) => { + e.preventDefault(); + const res = await signIn("credentials", { + email, + }); + + if (res?.error) { + setError("You are not a valid user."); + } else { + console.log("Login successful"); + } + }; + + const handleEmailChange = (e: { + target: { value: SetStateAction }; + }) => { + setEmail(e.target.value); + if (error) { + setError(undefined); // Clear the error when the user starts typing + } + }; + + // Sync initial error state with server-side error prop + useEffect(() => { + setError(initialError); + }, [initialError]); + + return ( +
+ {/* Wrapper to control width and layout */} +
+ {/* Email input field using shadcn input */} + + +
+ + {/* Reserve space for the error message */} +
+ {error &&

You are not a valid user.

} +
+ + ); +} diff --git a/src/app/components/signin/MicrosoftSignInButton.jsx b/src/app/components/signin/MicrosoftSignInButton.jsx index bb881fd3..f53399f4 100644 --- a/src/app/components/signin/MicrosoftSignInButton.jsx +++ b/src/app/components/signin/MicrosoftSignInButton.jsx @@ -13,7 +13,7 @@ const MicrosoftSignInButton = () => {
{kwh.toFixed(0)} kWh
- {carbon}t CO 2 + {carbon}t CO2
+ + {relevantDocuments.map((doc) => ( + + + {doc.documentType} + + + {descriptions[doc.documentType] || ""} + + + handleDownload(doc.documentLocation)} // Call the download handler + backgroundColor="brandgold" + /> + + + ))} + +
+ ); +}; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/page.tsx new file mode 100644 index 00000000..4522de87 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/page.tsx @@ -0,0 +1,188 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/app/shadcn_components/ui/card"; + +import { + getEnergyAssessment, + getEnergyAssessmentDocuments, + getPropertyMeta, +} from "../utils"; +import { DocumentsTable } from "./DocumentsTable"; +import { getEpcColorClass } from "@/app/utils"; + +// Helper function to clean scenario names +function cleanScenarioName(scenarioName: string | undefined) { + if (!scenarioName) { + return scenarioName; + } + + return scenarioName.startsWith("Scenario") + ? scenarioName.replace(/^Scenario\s*/i, "").trim() + : scenarioName; +} + +type InfoCardProps = { + title: string; + value: number | string; + unit: string; +}; + +const InfoCard: React.FC = ({ title, value, unit }) => { + const isEnergyRating = title === "Energy Rating"; + const bgColorClass = isEnergyRating ? getEpcColorClass(value.toString()) : ""; // Get the EPC color if it's Energy Rating + + return ( + + + + {title} + + + +
+ {value} + {unit} +
+
+
+ ); +}; + +export default async function EnergyAssessmentsPage({ + params, +}: { + params: { slug: string; propertyId: string }; +}) { + const propertyMeta = await getPropertyMeta(params.propertyId); + const ea = await getEnergyAssessment(propertyMeta.uprn); + + // ea will be an empty object {} if there is no energy assessment + + if (Object.keys(ea).length === 0) { + return ( +
+
+
+ This property does not have a Domna energy assessment +
+

Please check back later for updates.

+
+
+ ); + } + + const documents = await getEnergyAssessmentDocuments(ea.id); + + const table1AllowedTypes = [ + "EPR", + "Condition Report", + "Evidence Report", + "Summary Information", + "Floor Plan", + ]; + + const scenarioAllowedTypes = ["Scenario Draft EPC", "Scenario Site Notes"]; + + // Separate core documents and scenario-specific documents + const coreDocuments = documents.filter( + (doc) => + doc.scenarioId === null && table1AllowedTypes.includes(doc.documentType) + ); + + const scenarioDocuments = documents.filter( + (doc) => + doc.scenarioId !== null && scenarioAllowedTypes.includes(doc.documentType) + ); + + // Extract unique scenarios from the documents + const scenarios = Array.from( + new Set(scenarioDocuments.map((doc) => doc.scenario?.scenarioName)) + ).filter(Boolean); // Filter out any null or undefined scenario names + + return ( +
+ {/* EPC Card and Summary Information */} +
+ {/* EPC Rating Card */} + + + + + + + + + + {/* Estimated Hot Water kWh */} + +
+ + {/* Core Survey Documents */} +
+
+ Core Survey Documents +
+
+ +
+
+ + {/* Scenario-Specific Documents */} + {scenarios.map((scenarioName) => { + const scenarioDocs = scenarioDocuments.filter( + (doc) => doc.scenario?.scenarioName === scenarioName + ); + + return ( +
+
+ Scenario: {cleanScenarioName(scenarioName)} +
+ +
+ ); + })} +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index d33e968e..37ded61a 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -1,4 +1,3 @@ -import { recommendation } from "../../../../db/schema/recommendations"; import { Recommendation, planRecommendations, @@ -18,11 +17,53 @@ import { import { plan, Plan } from "@/app/db/schema/recommendations"; import { getRating } from "@/app/utils"; import { eq, desc } from "drizzle-orm"; +import { + energyAssessment, + EnergyAssessment, + energyAssessmentDocuments, + EnergyAssessmentDocument, + EnergyAssessmentDocumentWithScenario, +} from "@/app/db/schema/energy_assessments"; type RecommendationList = { recommendation: Recommendation; }[]; +export async function getEnergyAssessment( + uprn: number +): Promise { + const data = await db.query.energyAssessment.findFirst({ + where: eq(energyAssessment.uprn, BigInt(uprn)), + }); + + if (!data) { + // If there's no data, we return an empty array. This signififies that no energy assessment has been conducted + return {} as EnergyAssessment; + } + + return data; +} + +export async function getEnergyAssessmentDocuments( + energyAssessmentId: bigint +): Promise { + const data = await db.query.energyAssessmentDocuments.findMany({ + where: eq( + energyAssessmentDocuments.energyAssessmentId, + BigInt(energyAssessmentId) + ), + with: { + scenario: true, + }, + }); + + if (!data) { + throw new Error("Network response was not ok"); + } + + return data; +} + export async function getRecommendations( planId: string ): Promise { diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts index e1139886..fd0f610f 100644 --- a/src/types/recommendations.ts +++ b/src/types/recommendations.ts @@ -12,4 +12,7 @@ export interface RecommendationMetricMap { heating_control: number; secondary_heating: number; cylinder_thermostat: number; + trickle_vents: number; + mixed_glazing: number; + draught_proofing: number; } diff --git a/tailwind.config.js b/tailwind.config.js index 8e2051d0..7521e9e3 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -101,6 +101,7 @@ module.exports = { brandtan: "#d3b488", hovertan: "#947750", brandgold: "#f1bb06", + hovergold: "#c79d12", brandbrown: "#3d1e05", brandmidblue: "#3943b7", border: "hsl(var(--border))",