mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #106 from Hestia-Homes/eco-project-data
added filtering + inspections to front end
This commit is contained in:
commit
8cdb1ad8bc
18 changed files with 4807 additions and 332 deletions
243
src/app/api/auth/[...nextauth]/authOptions.ts
Normal file
243
src/app/api/auth/[...nextauth]/authOptions.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
|
||||
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
|
||||
|
||||
import { db } from "@/app/db/db";
|
||||
import {
|
||||
user as users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
} from "@/app/db/schema/users";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Environment variables
|
||||
// ------------------------------------------------------------------
|
||||
const {
|
||||
GOOGLE_CLIENT_ID = "",
|
||||
GOOGLE_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_TENANT_NAME = "",
|
||||
AZURE_AD_B2C_CLIENT_ID = "",
|
||||
AZURE_AD_B2C_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
|
||||
EMAIL_SERVER_HOST = "",
|
||||
EMAIL_SERVER_PORT = "",
|
||||
EMAIL_SERVER_USER = "",
|
||||
EMAIL_SERVER_PASSWORD = "",
|
||||
EMAIL_FROM = "",
|
||||
} = process.env;
|
||||
|
||||
type OauthProvider = "google" | "azure-ad-b2c";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NextAuth configuration
|
||||
// ------------------------------------------------------------------
|
||||
export const AuthOptions: NextAuthOptions = {
|
||||
adapter: DrizzleEmailAdapter(db, {
|
||||
user: users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
}),
|
||||
|
||||
providers: [
|
||||
// ------------------ Google ------------------
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
authorization: {
|
||||
params: {
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
response_type: "code",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Azure AD B2C ------------------
|
||||
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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Email (SES Magic Link) ------------------
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: EMAIL_SERVER_HOST,
|
||||
port: Number(EMAIL_SERVER_PORT),
|
||||
auth: {
|
||||
user: EMAIL_SERVER_USER,
|
||||
pass: EMAIL_SERVER_PASSWORD,
|
||||
},
|
||||
},
|
||||
from: EMAIL_FROM, // noreply email
|
||||
maxAge: 60 * 60, // magic link valid for 1 hour
|
||||
sendVerificationRequest: MagicLinksEmail,
|
||||
}),
|
||||
],
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pages
|
||||
// ------------------------------------------------------------------
|
||||
pages: {
|
||||
signIn: "/", // your landing/login page
|
||||
verifyRequest: "/auth/verify-request",
|
||||
error: "/auth/error",
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Callbacks
|
||||
// ------------------------------------------------------------------
|
||||
callbacks: {
|
||||
/**
|
||||
* Sign in callback — ensures user exists and links OAuth provider if needed
|
||||
*/
|
||||
async signIn({ user, account, profile }) {
|
||||
try {
|
||||
if (!user?.email) return false;
|
||||
const normalisedEmail = user.email.toLowerCase();
|
||||
|
||||
// Fetch the user (NextAuth will have created them already if new)
|
||||
const [dbUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
|
||||
// New user - next auth will handle
|
||||
if (!dbUser) {
|
||||
console.log("New user sign up for email:", normalisedEmail);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-link provider if same verified email but account not linked yet
|
||||
if (account?.provider && account.type === "oauth") {
|
||||
const existingLink = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, dbUser.id),
|
||||
eq(accounts.provider, account.provider)
|
||||
)
|
||||
);
|
||||
|
||||
const emailVerified =
|
||||
(profile as any)?.email_verified ?? account.provider === "google";
|
||||
|
||||
if (existingLink.length === 0 && emailVerified) {
|
||||
// This handles the case where we had not set up accounts but
|
||||
// signed up users with oauth
|
||||
console.log(
|
||||
`Linking ${account.provider} account for user ${normalisedEmail}`
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
userId: dbUser.id,
|
||||
type: account.type,
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
access_token: account.access_token,
|
||||
id_token: account.id_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
// Link OAuth ID if missing (helps for older accounts)
|
||||
if (account && !dbUser.oauthId) {
|
||||
console.log("Linking OAuth ID for user:", normalisedEmail);
|
||||
const provider = account.provider as OauthProvider;
|
||||
await db
|
||||
.update(users)
|
||||
.set({ oauthId: user.id, oauthProvider: provider })
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
}
|
||||
|
||||
// Always update last login timestamp
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastLogin: new Date() })
|
||||
.where(eq(users.id, dbUser.id));
|
||||
|
||||
// Pass bigint ID into NextAuth session/jwt
|
||||
user.dbId = dbUser.id.toString();
|
||||
user.onboarded = dbUser.onboarded ?? false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error during sign-in:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist dbId in the JWT so it’s available in sessions
|
||||
*/
|
||||
async jwt({ token, user: userFromLogin }) {
|
||||
// Initial sign-in: attach DB fields
|
||||
if (userFromLogin) {
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, userFromLogin.email!),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
} else if (token.email) {
|
||||
// On subsequent calls, keep token synced from DB
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, token.email),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
}
|
||||
|
||||
if (userFromLogin?.dbId) {
|
||||
token.dbId = userFromLogin.dbId;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach dbId to session.user
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.dbId) {
|
||||
session.user.dbId = token.dbId;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
/**
|
||||
* Redirect users after login
|
||||
*/
|
||||
async redirect({ baseUrl }) {
|
||||
// If the user has not onboarded, send them to onboarding
|
||||
return `${baseUrl}/home`;
|
||||
},
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// General options
|
||||
// ------------------------------------------------------------------
|
||||
session: {
|
||||
strategy: "jwt", // lightweight, avoids DB session writes
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
debug: false,
|
||||
};
|
||||
|
|
@ -1,246 +1,5 @@
|
|||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
|
||||
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
|
||||
|
||||
import { db } from "@/app/db/db";
|
||||
import {
|
||||
user as users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
} from "@/app/db/schema/users";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Environment variables
|
||||
// ------------------------------------------------------------------
|
||||
const {
|
||||
GOOGLE_CLIENT_ID = "",
|
||||
GOOGLE_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_TENANT_NAME = "",
|
||||
AZURE_AD_B2C_CLIENT_ID = "",
|
||||
AZURE_AD_B2C_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
|
||||
EMAIL_SERVER_HOST = "",
|
||||
EMAIL_SERVER_PORT = "",
|
||||
EMAIL_SERVER_USER = "",
|
||||
EMAIL_SERVER_PASSWORD = "",
|
||||
EMAIL_FROM = "",
|
||||
} = process.env;
|
||||
|
||||
type OauthProvider = "google" | "azure-ad-b2c";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NextAuth configuration
|
||||
// ------------------------------------------------------------------
|
||||
export const AuthOptions: NextAuthOptions = {
|
||||
adapter: DrizzleEmailAdapter(db, {
|
||||
user: users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
}),
|
||||
|
||||
providers: [
|
||||
// ------------------ Google ------------------
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
authorization: {
|
||||
params: {
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
response_type: "code",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Azure AD B2C ------------------
|
||||
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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Email (SES Magic Link) ------------------
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: EMAIL_SERVER_HOST,
|
||||
port: Number(EMAIL_SERVER_PORT),
|
||||
auth: {
|
||||
user: EMAIL_SERVER_USER,
|
||||
pass: EMAIL_SERVER_PASSWORD,
|
||||
},
|
||||
},
|
||||
from: EMAIL_FROM, // noreply email
|
||||
maxAge: 60 * 60, // magic link valid for 1 hour
|
||||
sendVerificationRequest: MagicLinksEmail,
|
||||
}),
|
||||
],
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pages
|
||||
// ------------------------------------------------------------------
|
||||
pages: {
|
||||
signIn: "/", // your landing/login page
|
||||
verifyRequest: "/auth/verify-request",
|
||||
error: "/auth/error",
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Callbacks
|
||||
// ------------------------------------------------------------------
|
||||
callbacks: {
|
||||
/**
|
||||
* Sign in callback — ensures user exists and links OAuth provider if needed
|
||||
*/
|
||||
async signIn({ user, account, profile }) {
|
||||
try {
|
||||
if (!user?.email) return false;
|
||||
const normalisedEmail = user.email.toLowerCase();
|
||||
|
||||
// Fetch the user (NextAuth will have created them already if new)
|
||||
const [dbUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
|
||||
// New user - next auth will handle
|
||||
if (!dbUser) {
|
||||
console.log("New user sign up for email:", normalisedEmail);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-link provider if same verified email but account not linked yet
|
||||
if (account?.provider && account.type === "oauth") {
|
||||
const existingLink = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, dbUser.id),
|
||||
eq(accounts.provider, account.provider)
|
||||
)
|
||||
);
|
||||
|
||||
const emailVerified =
|
||||
(profile as any)?.email_verified ?? account.provider === "google";
|
||||
|
||||
if (existingLink.length === 0 && emailVerified) {
|
||||
// This handles the case where we had not set up accounts but
|
||||
// signed up users with oauth
|
||||
console.log(
|
||||
`Linking ${account.provider} account for user ${normalisedEmail}`
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
userId: dbUser.id,
|
||||
type: account.type,
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
access_token: account.access_token,
|
||||
id_token: account.id_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
// Link OAuth ID if missing (helps for older accounts)
|
||||
if (account && !dbUser.oauthId) {
|
||||
console.log("Linking OAuth ID for user:", normalisedEmail);
|
||||
const provider = account.provider as OauthProvider;
|
||||
await db
|
||||
.update(users)
|
||||
.set({ oauthId: user.id, oauthProvider: provider })
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
}
|
||||
|
||||
// Always update last login timestamp
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastLogin: new Date() })
|
||||
.where(eq(users.id, dbUser.id));
|
||||
|
||||
// Pass bigint ID into NextAuth session/jwt
|
||||
user.dbId = dbUser.id.toString();
|
||||
user.onboarded = dbUser.onboarded ?? false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error during sign-in:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist dbId in the JWT so it’s available in sessions
|
||||
*/
|
||||
async jwt({ token, user: userFromLogin }) {
|
||||
// Initial sign-in: attach DB fields
|
||||
if (userFromLogin) {
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, userFromLogin.email!),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
} else if (token.email) {
|
||||
// On subsequent calls, keep token synced from DB
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, token.email),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
}
|
||||
|
||||
if (userFromLogin?.dbId) {
|
||||
token.dbId = userFromLogin.dbId;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach dbId to session.user
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.dbId) {
|
||||
session.user.dbId = token.dbId;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
/**
|
||||
* Redirect users after login
|
||||
*/
|
||||
async redirect({ baseUrl }) {
|
||||
// If the user has not onboarded, send them to onboarding
|
||||
return `${baseUrl}/home`;
|
||||
},
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// General options
|
||||
// ------------------------------------------------------------------
|
||||
session: {
|
||||
strategy: "jwt", // lightweight, avoids DB session writes
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
debug: false,
|
||||
};
|
||||
import NextAuth from "next-auth";
|
||||
import { AuthOptions } from "./authOptions";
|
||||
|
||||
const handler = NextAuth(AuthOptions);
|
||||
export { handler as GET, handler as POST };
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { db } from "@/app/db/db";
|
|||
import { user } from "@/app/db/schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
|
||||
const OnboardedSchema = z.object({
|
||||
onboarded: z.boolean(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { db } from "@/app/db/db";
|
|||
import { userProfiles, user } from "@/app/db/schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
|
||||
const UserProfileSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import {
|
|||
HoverCardTrigger,
|
||||
} from "@/app/shadcn_components/ui/hover-card";
|
||||
|
||||
type ExtendedStatus = (typeof PortfolioStatus)[number] | "ECO4" | "GBIS";
|
||||
|
||||
export default function StatusBadge({
|
||||
status,
|
||||
isProperty = false,
|
||||
}: {
|
||||
status: (typeof PortfolioStatus)[number];
|
||||
status: ExtendedStatus;
|
||||
isProperty?: boolean;
|
||||
}) {
|
||||
const statusConfig = statusColor[status];
|
||||
|
|
@ -45,7 +47,7 @@ export default function StatusBadge({
|
|||
}
|
||||
|
||||
const statusColor: {
|
||||
[key in (typeof PortfolioStatus)[number]]: {
|
||||
[key in ExtendedStatus]: {
|
||||
class: string;
|
||||
text: string;
|
||||
hoverText: string;
|
||||
|
|
@ -59,8 +61,9 @@ const statusColor: {
|
|||
propertyHoverText: "This property is currently in scoping",
|
||||
},
|
||||
assessment: {
|
||||
class: "bg-emerald-400 hover:bg-emerald-500 truncate text-overflow: ellipsis",
|
||||
text: "Non-invasive Assessment",
|
||||
class:
|
||||
"bg-emerald-400 hover:bg-emerald-500 truncate text-overflow: ellipsis",
|
||||
text: "Remote Assessment",
|
||||
hoverText: "This portfolio is currently in the assessment stage",
|
||||
propertyHoverText: "This property is currently in the assessment stage",
|
||||
},
|
||||
|
|
@ -114,4 +117,16 @@ const statusColor: {
|
|||
hoverText: "The works in this portfolio has been completed and need review",
|
||||
propertyHoverText: "The works on this property have been completed",
|
||||
},
|
||||
ECO4: {
|
||||
class: "bg-brandblue hover:bg-hoverblue",
|
||||
text: "ECO4",
|
||||
hoverText: "This property is funded under the ECO4 scheme",
|
||||
propertyHoverText: "This property is funded under the ECO4 scheme",
|
||||
},
|
||||
GBIS: {
|
||||
class: "bg-brandmidblue hover:bg-hoverblue",
|
||||
text: "GBIS",
|
||||
hoverText: "This property is funded under the GBIS scheme",
|
||||
propertyHoverText: "This property is funded under the GBIS scheme",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
35
src/app/db/migrations/0122_yielding_morlocks.sql
Normal file
35
src/app/db/migrations/0122_yielding_morlocks.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
CREATE TYPE "public"."inspection_archetype_2" AS ENUM('detached', 'mid-terrace', 'enclosed mid-terrace', 'end-terrace', 'enclosed end-terrace', 'semi-detached');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspection_archetype" AS ENUM('Bungalow', 'Flat', 'Maisonette', 'House', 'non-domestic');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspection_borescoped" AS ENUM('yes', 'no', 'refused');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_access_issues" AS ENUM('see notes', 'damp issues', 'foliage on walls', 'bushes against wall', 'trees around/anove property', 'high rise block flats/maisonettes', 'conservatory', 'lean-to', 'garage', 'extension', 'decking', 'shed against wall');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_cladding" AS ENUM('none', 'cladded with “sufficient space to fill the wall”', 'cladded with “insufficient space to fill the wall”');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_insulation_material" AS ENUM('empty 50-90', 'empty 100+', 'empty 30-40', 'empty less than 30', 'loose fibre/wool', 'eps/celo/king', 'fibre batts - with cavity', 'fibre batts - no cavity', 'loose bead', 'glued bead', 'formaldehyde', 'bubble wrap', 'poly chunks');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_rendered" AS ENUM('no render', 'rendered with “insufficient” space between dpc and render', 'rendered with “sufficient” space between dpc and render');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_roof_orientation" AS ENUM('north', 'east', 'south', 'west', 'north-east', 'north-west', 'south-east', 'south-west', 'n/s split', 'e/w split', 'ne/sw split', 'nw/se split', 'flat roof', 'no roof', 'roof too small', 'already has solar pv');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_tile_hung" AS ENUM('yes', 'no', 'first floor flats are tile hung');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_wall_construction" AS ENUM('cavity', 'solid', 'system built', 'timber framed', 'steel framed', 're-walled cavity', 'mansard pre-fab', 'mansard ewi', 'mansard re-walled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_wall_insulation" AS ENUM('empty cavity', 'filled at build', 'partial', 'retro drilled', 'ewi', 'iwi', 'solid non-cavity', 'system built', 'timber framed', 'steel framed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."plan_type" AS ENUM('solar_eco4', 'solar_hhrsh_eco4', 'empty_cavity_eco', 'partial_cavity_eco', 'extraction_eco');--> statement-breakpoint
|
||||
CREATE TABLE "inspections" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"property_id" bigint NOT NULL,
|
||||
"archetype" "inspection_archetype",
|
||||
"archetype_2" "inspection_archetype_2",
|
||||
"wall_construction" "inspections_wall_construction",
|
||||
"insulation" "inspections_wall_insulation",
|
||||
"insulation_material" "inspections_insulation_material",
|
||||
"borescoped" "inspection_borescoped",
|
||||
"roof_orientation" "inspections_roof_orientation",
|
||||
"tile_hung" "inspections_tile_hung",
|
||||
"rendered" "inspections_rendered",
|
||||
"cladding" "inspections_cladding",
|
||||
"access_issues" "inspections_access_issues",
|
||||
"notes" text,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"surveyor_name" text,
|
||||
"uploaded_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "property" ADD COLUMN "landlord_property_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "plan" ADD COLUMN "plan_type" "plan_type";--> statement-breakpoint
|
||||
ALTER TABLE "inspections" ADD CONSTRAINT "inspections_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE no action ON UPDATE no action;
|
||||
4189
src/app/db/migrations/meta/0122_snapshot.json
Normal file
4189
src/app/db/migrations/meta/0122_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -855,6 +855,13 @@
|
|||
"when": 1761218186670,
|
||||
"tag": "0121_chunky_tony_stark",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 122,
|
||||
"version": "7",
|
||||
"when": 1761587998488,
|
||||
"tag": "0122_yielding_morlocks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
190
src/app/db/schema/inspections.ts
Normal file
190
src/app/db/schema/inspections.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import {
|
||||
bigserial,
|
||||
text,
|
||||
timestamp,
|
||||
pgTable,
|
||||
pgEnum,
|
||||
bigint,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { property } from "./property";
|
||||
|
||||
const inspection_archetypes: [string, ...string[]] = [
|
||||
"Bungalow",
|
||||
"Flat",
|
||||
"Maisonette",
|
||||
"House",
|
||||
"non-domestic",
|
||||
];
|
||||
export const inspectionArchetypeEnum = pgEnum(
|
||||
"inspection_archetype",
|
||||
inspection_archetypes
|
||||
);
|
||||
|
||||
const inspection_archetypes_2: [string, ...string[]] = [
|
||||
"detached",
|
||||
"mid-terrace",
|
||||
"enclosed mid-terrace",
|
||||
"end-terrace",
|
||||
"enclosed end-terrace",
|
||||
"semi-detached",
|
||||
];
|
||||
export const inspectionArchetype2Enum = pgEnum(
|
||||
"inspection_archetype_2",
|
||||
inspection_archetypes_2
|
||||
);
|
||||
|
||||
const inspections_wall_constructions: [string, ...string[]] = [
|
||||
"cavity",
|
||||
"solid",
|
||||
"system built",
|
||||
"timber framed",
|
||||
"steel framed",
|
||||
"re-walled cavity",
|
||||
"mansard pre-fab",
|
||||
"mansard ewi",
|
||||
"mansard re-walled",
|
||||
];
|
||||
export const inspectionsWallConstructionEnum = pgEnum(
|
||||
"inspections_wall_construction",
|
||||
inspections_wall_constructions
|
||||
);
|
||||
|
||||
const inspections_wall_insulation: [string, ...string[]] = [
|
||||
"empty cavity",
|
||||
"filled at build",
|
||||
"partial",
|
||||
"retro drilled",
|
||||
"ewi",
|
||||
"iwi",
|
||||
"solid non-cavity",
|
||||
"system built",
|
||||
"timber framed",
|
||||
"steel framed",
|
||||
];
|
||||
export const inspectionsWallInsulationEnum = pgEnum(
|
||||
"inspections_wall_insulation",
|
||||
inspections_wall_insulation
|
||||
);
|
||||
|
||||
const inspectionsInsulationMaterial: [string, ...string[]] = [
|
||||
"empty 50-90",
|
||||
"empty 100+",
|
||||
"empty 30-40",
|
||||
"empty less than 30",
|
||||
"loose fibre/wool",
|
||||
"eps/celo/king",
|
||||
"fibre batts - with cavity",
|
||||
"fibre batts - no cavity",
|
||||
"loose bead",
|
||||
"glued bead",
|
||||
"formaldehyde",
|
||||
"bubble wrap",
|
||||
"poly chunks",
|
||||
];
|
||||
export const inspectionsInsulationMaterialEnum = pgEnum(
|
||||
"inspections_insulation_material",
|
||||
inspectionsInsulationMaterial
|
||||
);
|
||||
|
||||
const inspectionBorescoped: [string, ...string[]] = ["yes", "no", "refused"];
|
||||
export const inspectionBorescopedEnum = pgEnum(
|
||||
"inspection_borescoped",
|
||||
inspectionBorescoped
|
||||
);
|
||||
|
||||
const inspectionsRoofOrientations: [string, ...string[]] = [
|
||||
"north",
|
||||
"east",
|
||||
"south",
|
||||
"west",
|
||||
"north-east",
|
||||
"north-west",
|
||||
"south-east",
|
||||
"south-west",
|
||||
"n/s split",
|
||||
"e/w split",
|
||||
"ne/sw split",
|
||||
"nw/se split",
|
||||
"flat roof",
|
||||
"no roof",
|
||||
"roof too small",
|
||||
"already has solar pv",
|
||||
];
|
||||
export const inspectionsRoofOrientationEnum = pgEnum(
|
||||
"inspections_roof_orientation",
|
||||
inspectionsRoofOrientations
|
||||
);
|
||||
|
||||
const inspectionsTileHung: [string, ...string[]] = [
|
||||
"yes",
|
||||
"no",
|
||||
"first floor flats are tile hung",
|
||||
];
|
||||
export const inspectionsTileHungEnum = pgEnum(
|
||||
"inspections_tile_hung",
|
||||
inspectionsTileHung
|
||||
);
|
||||
|
||||
const renderedOptions: [string, ...string[]] = [
|
||||
"no render",
|
||||
"rendered with “insufficient” space between dpc and render",
|
||||
"rendered with “sufficient” space between dpc and render",
|
||||
];
|
||||
export const inspectionsRenderedEnum = pgEnum(
|
||||
"inspections_rendered",
|
||||
renderedOptions
|
||||
);
|
||||
|
||||
const claddingOptions: [string, ...string[]] = [
|
||||
"none",
|
||||
"cladded with “sufficient space to fill the wall”",
|
||||
"cladded with “insufficient space to fill the wall”",
|
||||
];
|
||||
|
||||
export const inspectionsCladdingEnum = pgEnum(
|
||||
"inspections_cladding",
|
||||
claddingOptions
|
||||
);
|
||||
|
||||
const access_issuesOptions: [string, ...string[]] = [
|
||||
"see notes",
|
||||
"damp issues",
|
||||
"foliage on walls",
|
||||
"bushes against wall",
|
||||
"trees around/anove property",
|
||||
"high rise block flats/maisonettes",
|
||||
"conservatory",
|
||||
"lean-to",
|
||||
"garage",
|
||||
"extension",
|
||||
"decking",
|
||||
"shed against wall",
|
||||
];
|
||||
|
||||
export const inspectionsAccessIssuesEnum = pgEnum(
|
||||
"inspections_access_issues",
|
||||
access_issuesOptions
|
||||
);
|
||||
|
||||
export const inspections = pgTable("inspections", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
propertyId: bigint("property_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => property.id),
|
||||
// TODO Revise
|
||||
archetype: inspectionArchetypeEnum("archetype"),
|
||||
archetype2: inspectionArchetype2Enum("archetype_2"),
|
||||
wallConstruction: inspectionsWallConstructionEnum("wall_construction"),
|
||||
insulation: inspectionsWallInsulationEnum("insulation"),
|
||||
insulationMaterial: inspectionsInsulationMaterialEnum("insulation_material"),
|
||||
borescoped: inspectionBorescopedEnum("borescoped"),
|
||||
roofOrientation: inspectionsRoofOrientationEnum("roof_orientation"),
|
||||
tileHung: inspectionsTileHungEnum("tile_hung"),
|
||||
rendered: inspectionsRenderedEnum("rendered"),
|
||||
cladding: inspectionsCladdingEnum("cladding"),
|
||||
access_issues: inspectionsAccessIssuesEnum("access_issues"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
surveyorName: text("surveyor_name"),
|
||||
uploadedAt: timestamp("uploaded_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -97,6 +97,7 @@ export const property = pgTable("property", {
|
|||
.references(() => portfolio.id),
|
||||
creationStatus: propertyCreationStatusEnum("creation_status").notNull(),
|
||||
uprn: bigint("uprn", { mode: "bigint" }),
|
||||
landlordPropertyId: text("landlord_property_id"), // Optional ID used by landlords
|
||||
buildingReferenceNumber: bigint("building_reference_number", {
|
||||
mode: "bigint",
|
||||
}),
|
||||
|
|
@ -268,6 +269,13 @@ export interface PropertyWithRelations {
|
|||
cost?: number | null;
|
||||
currentEpcRating: string | null;
|
||||
currentSapPoints: number | null;
|
||||
plans: {
|
||||
id: bigint;
|
||||
isDefault?: boolean;
|
||||
fundingPackage?: {
|
||||
scheme: string | null;
|
||||
} | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type NonIntrusiveSurveyNotes = InferModel<
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ export const recommendationMaterials = pgTable("recommendation_materials", {
|
|||
estimatedCost: real("estimated_cost").notNull(),
|
||||
});
|
||||
|
||||
// We create a plan type, for common plan types that we produce for clients
|
||||
const PlanType: [string, ...string[]] = [
|
||||
"solar_eco4",
|
||||
"solar_hhrsh_eco4",
|
||||
"empty_cavity_eco",
|
||||
"partial_cavity_eco",
|
||||
"extraction_eco",
|
||||
];
|
||||
export const planTypeEnum = pgEnum("plan_type", PlanType);
|
||||
|
||||
export const plan = pgTable("plan", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
name: text("name"),
|
||||
|
|
@ -82,6 +92,7 @@ export const plan = pgTable("plan", {
|
|||
valuationIncreaseLowerBound: real("valuation_increase_lower_bound"),
|
||||
valuationIncreaseUpperBound: real("valuation_increase_upper_bound"),
|
||||
valuationIncreaseAverage: real("valuation_increase_average"),
|
||||
planType: planTypeEnum("plan_type"), // This may be null for custom plans, outside of our common plan types
|
||||
});
|
||||
|
||||
export const planRecommendations = pgTable("plan_recommendations", {
|
||||
|
|
@ -272,5 +283,5 @@ export const MeasureKeyEnum = z.enum([
|
|||
...Object.keys(measuresDisplayLabels),
|
||||
] as [
|
||||
MeasureKey, // Force at least one measure key
|
||||
...MeasureKey[]
|
||||
...MeasureKey[],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -62,10 +62,19 @@ export const recommendationMaterialsRelations = relations(
|
|||
// create a one to many relation to map a plan to the details in the underlying recommendation
|
||||
// create a many to many map from a plan to a recommendation
|
||||
// A recommendation can be in multiple plans and therefore we have a many to many relationship between
|
||||
// plan and recommendations. This relationship is facilitated by the planRecommdnations table
|
||||
// plan and recommendations. This relationship is facilitated by the planRecommendations table.
|
||||
// We also need to be able to get from a plan to its property
|
||||
|
||||
export const planRelations = relations(plan, ({ many }) => ({
|
||||
export const planRelations = relations(plan, ({ one, many }) => ({
|
||||
property: one(property, {
|
||||
fields: [plan.propertyId],
|
||||
references: [property.id],
|
||||
}),
|
||||
planRecommendations: many(planRecommendations),
|
||||
fundingPackage: one(fundingPackage, {
|
||||
fields: [plan.id],
|
||||
references: [fundingPackage.planId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const planRecommendationsRelations = relations(
|
||||
|
|
@ -83,6 +92,7 @@ export const planRecommendationsRelations = relations(
|
|||
);
|
||||
|
||||
// one to one relationship between property and propertyTargets, and also to the recommendations table
|
||||
// We also relate a property to it's many plans, from which we can take the default
|
||||
export const propertyRelations = relations(property, ({ one, many }) => ({
|
||||
target: one(propertyTargets, {
|
||||
fields: [property.id],
|
||||
|
|
@ -93,6 +103,7 @@ export const propertyRelations = relations(property, ({ one, many }) => ({
|
|||
fields: [property.id],
|
||||
references: [propertyDetailsEpc.propertyId],
|
||||
}),
|
||||
plans: many(plan),
|
||||
}));
|
||||
|
||||
// We have a many to many relationship between users and portfolios
|
||||
|
|
@ -159,15 +170,25 @@ export const energyAssessmentDocumentsRelations = relations(
|
|||
// Relation from a funding package to funding package measures
|
||||
// Define a relation from a EnergyAssessmentDocument to EnergyAssessmentScenario. This is a many to one
|
||||
|
||||
// funding package links to multiple funding package measures
|
||||
export const fundingPackageRelations = relations(fundingPackage, ({ many }) => ({
|
||||
fundingPackageMeasures: many(fundingPackageMeasures),
|
||||
}));
|
||||
// funding package links to multiple funding package measures. We also link to a plan
|
||||
export const fundingPackageRelations = relations(
|
||||
fundingPackage,
|
||||
({ one, many }) => ({
|
||||
plan: one(plan, {
|
||||
fields: [fundingPackage.planId],
|
||||
references: [plan.id],
|
||||
}),
|
||||
fundingPackageMeasures: many(fundingPackageMeasures),
|
||||
})
|
||||
);
|
||||
|
||||
// funding package measures belong to a funding package
|
||||
export const fundingPackageMeasuresRelations = relations(fundingPackageMeasures, ({ one }) => ({
|
||||
fundingPackage: one(fundingPackage, {
|
||||
fields: [fundingPackageMeasures.fundingPackageId],
|
||||
references: [fundingPackage.id],
|
||||
}),
|
||||
}));
|
||||
export const fundingPackageMeasuresRelations = relations(
|
||||
fundingPackageMeasures,
|
||||
({ one }) => ({
|
||||
fundingPackage: one(fundingPackage, {
|
||||
fields: [fundingPackageMeasures.fundingPackageId],
|
||||
references: [fundingPackage.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import CardTiles from "../components/home/CardTiles";
|
||||
import { getPortfolios } from "./utils";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import "./globals.css";
|
|||
import Provider from "./components/Provider";
|
||||
import Nav from "./components/Navbar";
|
||||
import { ReactQueryProvider } from "./ReactQueryProvider";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { cache } from "react";
|
||||
import { Inter } from "next/font/google";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getServerSession } from "next-auth/next";
|
||||
import { AuthOptions } from "./api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "./api/auth/[...nextauth]/authOptions";
|
||||
import GoogleSignInButton from "./components/signin/GoogleSignInButton";
|
||||
import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton";
|
||||
import EmailSignInButton from "./components/signin/EmailSignInButton";
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ function EmptyPropertyState() {
|
|||
);
|
||||
}
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [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
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
PropertyWithRelations,
|
||||
} from "@/app/db/schema/property";
|
||||
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
|
|
@ -41,13 +40,11 @@ const EpcLetterBubble = ({ letter }: { letter: string }) => {
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
export function DataTableFilterHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
|
@ -66,10 +63,11 @@ export function DataTableFilterHeader<TData, TValue>({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{PortfolioStatus.map((status) => (
|
||||
{[...PortfolioStatus, "ECO4", "GBIS"].map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={() => {
|
||||
console.log("status filter:", status);
|
||||
column.setFilterValue(status);
|
||||
}}
|
||||
>
|
||||
|
|
@ -130,10 +128,22 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
|
|||
},
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") ?? "";
|
||||
const plans = row.original.plans || [];
|
||||
// Check if any plan has an ECO4 or GBIS funding package
|
||||
const fundingScheme = plans.find((p) => {
|
||||
const scheme = p?.fundingPackage?.scheme;
|
||||
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase());
|
||||
})?.fundingPackage?.scheme;
|
||||
|
||||
const effectiveStatus = fundingScheme
|
||||
? fundingScheme.toUpperCase()
|
||||
: status;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{status && <StatusBadge status={String(status)} isProperty={true} />}
|
||||
{effectiveStatus && (
|
||||
<StatusBadge status={String(effectiveStatus)} isProperty={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -114,7 +114,6 @@ export interface DataItem {
|
|||
scenarios: Scenario[];
|
||||
}
|
||||
|
||||
|
||||
export async function getOverviewPortfolioData(
|
||||
portfolioId: string
|
||||
): Promise<DataItem[]> {
|
||||
|
|
@ -313,7 +312,11 @@ export async function getOverviewPortfolioData(
|
|||
{ scenarioName: "Today", data: "" },
|
||||
{
|
||||
scenarioName: portfolioName || "Default",
|
||||
data: "£" + formatNumber((data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)),
|
||||
data:
|
||||
"£" +
|
||||
formatNumber(
|
||||
(data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -333,7 +336,11 @@ export async function getOverviewPortfolioData(
|
|||
{ scenarioName: "Today", data: "" },
|
||||
{
|
||||
scenarioName: portfolioName || "Default",
|
||||
data: "£" + formatNumber((data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)),
|
||||
data:
|
||||
"£" +
|
||||
formatNumber(
|
||||
(data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -410,56 +417,6 @@ export async function getProperties(
|
|||
limit: number = 1000,
|
||||
offset: number = 0
|
||||
): Promise<PropertyWithRelations[]> {
|
||||
// When pulling in the associated recommendations, we need to pull in the associated plan and take recommendations that are associated to the
|
||||
// default plans
|
||||
|
||||
// const data: PropertyWithRelations[] = await db.query.property.findMany({
|
||||
// limit: limit,
|
||||
// offset: offset,
|
||||
// columns: {
|
||||
// id: true,
|
||||
// portfolioId: true,
|
||||
// address: true,
|
||||
// postcode: true,
|
||||
// status: true,
|
||||
// creationStatus: true,
|
||||
// currentEpcRating: true,
|
||||
// currentSapPoints: true,
|
||||
// },
|
||||
// where: eq(property.portfolioId, BigInt(portfolioId)),
|
||||
// with: {
|
||||
// target: {
|
||||
// columns: {
|
||||
// epc: true,
|
||||
// },
|
||||
// },
|
||||
// recommendations: {
|
||||
// columns: {
|
||||
// id: true,
|
||||
// estimatedCost: true,
|
||||
// sapPoints: true,
|
||||
// },
|
||||
// where: eq(recommendation.default, true),
|
||||
// with: {
|
||||
// planRecommendations: {
|
||||
// columns: {
|
||||
// planId: true,
|
||||
// },
|
||||
// with: {
|
||||
// plan: {
|
||||
// columns: {
|
||||
// id: true,
|
||||
// isDefault: true,
|
||||
// },
|
||||
// where: eq(plan.isDefault, true),
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// We need to perform the query like this because the nested query is not supported in the ORM right now
|
||||
const data: PropertyWithRelations[] = await db.query.property.findMany({
|
||||
limit: limit,
|
||||
|
|
@ -501,10 +458,37 @@ export async function getProperties(
|
|||
)
|
||||
),
|
||||
},
|
||||
plans: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
where: eq(plan.isDefault, true),
|
||||
// Associate the funding information
|
||||
with: {
|
||||
fundingPackage: {
|
||||
columns: {
|
||||
scheme: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
// override status to reflect ECO4/GBIS if present
|
||||
const updated = data.map((p) => {
|
||||
const fundingScheme = p.plans.find((pl) => {
|
||||
const scheme = pl?.fundingPackage?.scheme;
|
||||
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase());
|
||||
})?.fundingPackage?.scheme;
|
||||
|
||||
return {
|
||||
...p,
|
||||
status: fundingScheme ? fundingScheme.toUpperCase() : p.status,
|
||||
};
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
interface UnaggregatedPortfolioPlanRecommendation {
|
||||
|
|
@ -545,7 +529,8 @@ function aggregateRecommendations(
|
|||
};
|
||||
} else {
|
||||
// if quantity is null previously, set to 0
|
||||
grouped[key].quantity = (grouped[key].quantity ?? 0) + (item.quantity ?? 0);
|
||||
grouped[key].quantity =
|
||||
(grouped[key].quantity ?? 0) + (item.quantity ?? 0);
|
||||
grouped[key].estimatedCost += item.estimatedCost;
|
||||
|
||||
if (item.propertyId) {
|
||||
|
|
@ -560,7 +545,9 @@ function aggregateRecommendations(
|
|||
|
||||
// Round the results to 2 decimal places, compute uniquePropertyCount, and count unique measureTypes
|
||||
for (const key in grouped) {
|
||||
grouped[key].quantity = parseFloat(grouped[key].quantity?.toFixed(2) || "0.00");
|
||||
grouped[key].quantity = parseFloat(
|
||||
grouped[key].quantity?.toFixed(2) || "0.00"
|
||||
);
|
||||
grouped[key].estimatedCost = parseFloat(
|
||||
grouped[key].estimatedCost.toFixed(2)
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue