From 7d51fcc4dcc952772fc0b675d8a1444cf108fbf4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Oct 2025 08:52:34 +0000 Subject: [PATCH 1/8] working on sign in and onboarding --- package-lock.json | 165 +- package.json | 5 +- .../auth/[...nextauth]/DrizzleEmailAdapter.ts | 276 ++ src/app/api/auth/[...nextauth]/route.ts | 184 +- .../[portfolioId]/colloborators/route.ts | 18 +- src/app/auth/verify-request/page.tsx | 14 + src/app/beta/page.tsx | 3 - ...ntialsButton.tsx => EmailSignInButton.tsx} | 39 +- .../db/migrations/0117_colossal_bastion.sql | 35 + src/app/db/migrations/meta/0117_snapshot.json | 3587 +++++++++++++++++ src/app/db/migrations/meta/_journal.json | 7 + src/app/db/schema/users.ts | 86 +- src/app/onboarding/page.tsx | 50 + src/app/page.tsx | 10 +- 14 files changed, 4348 insertions(+), 131 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts create mode 100644 src/app/auth/verify-request/page.tsx delete mode 100644 src/app/beta/page.tsx rename src/app/components/signin/{CredentialsButton.tsx => EmailSignInButton.tsx} (61%) create mode 100644 src/app/db/migrations/0117_colossal_bastion.sql create mode 100644 src/app/db/migrations/meta/0117_snapshot.json create mode 100644 src/app/onboarding/page.tsx diff --git a/package-lock.json b/package-lock.json index aceec29..c0f037b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,8 +38,7 @@ "aws-sdk": "^2.1415.0", "class-variance-authority": "^0.6.1", "clsx": "^1.2.1", - "drizzle-kit": "^0.31.4", - "drizzle-orm": "^0.44.3", + "drizzle-orm": "^0.44.5", "esbuild": "^0.25.8", "eslint-config-next": "13.4.3", "lucide-react": "^0.233.0", @@ -47,6 +46,7 @@ "next-auth": "^4.22.1", "next-axiom": "^1.9.2", "next-themes": "^0.3.0", + "nodemailer": "^6.10.1", "pg": "^8.11.1", "postcss": "^8.5.6", "react": "18.3.1", @@ -68,6 +68,7 @@ "cypress": "^14.5.3", "cypress-social-logins": "^1.14.1", "dotenv": "^16.3.1", + "drizzle-kit": "^0.31.5", "eslint": "^8.57.1", "prettier": "^3.6.2", "start-server-and-test": "^2.0.0" @@ -85,6 +86,95 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz", + "integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/core/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@auth/core/node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -826,6 +916,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@emnapi/core": { @@ -864,6 +955,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.18.20", @@ -877,6 +969,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -893,6 +986,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -909,6 +1003,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -925,6 +1020,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -941,6 +1037,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -957,6 +1054,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -973,6 +1071,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -989,6 +1088,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1005,6 +1105,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1021,6 +1122,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1037,6 +1139,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1053,6 +1156,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1069,6 +1173,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1085,6 +1190,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1101,6 +1207,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1117,6 +1224,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1133,6 +1241,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1149,6 +1258,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1165,6 +1275,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1181,6 +1292,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1197,6 +1309,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1213,6 +1326,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1226,6 +1340,7 @@ "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1264,6 +1379,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, "license": "MIT", "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", @@ -5470,6 +5586,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -6849,6 +6973,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/cachedir": { @@ -7879,9 +8004,10 @@ } }, "node_modules/drizzle-kit": { - "version": "0.31.4", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz", - "integrity": "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==", + "version": "0.31.5", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.5.tgz", + "integrity": "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==", + "dev": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", @@ -7894,9 +8020,9 @@ } }, "node_modules/drizzle-orm": { - "version": "0.44.4", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.4.tgz", - "integrity": "sha512-ZyzKFpTC/Ut3fIqc2c0dPZ6nhchQXriTsqTNs4ayRgl6sZcFlMs9QZKPSHXK4bdOf41GHGWf+FrpcDDYwW+W6Q==", + "version": "0.44.5", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.5.tgz", + "integrity": "sha512-jBe37K7d8ZSKptdKfakQFdeljtu3P2Cbo7tJoJSVZADzIKOBo9IAJPOmMsH2bZl90bZgh8FQlD8BjxXA/zuBkQ==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", @@ -8333,6 +8459,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4" @@ -11150,6 +11277,15 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -11187,6 +11323,17 @@ "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -13044,6 +13191,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -13062,6 +13210,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", diff --git a/package.json b/package.json index a8f5675..c07de30 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,7 @@ "aws-sdk": "^2.1415.0", "class-variance-authority": "^0.6.1", "clsx": "^1.2.1", - "drizzle-kit": "^0.31.4", - "drizzle-orm": "^0.44.3", + "drizzle-orm": "^0.44.5", "esbuild": "^0.25.8", "eslint-config-next": "13.4.3", "lucide-react": "^0.233.0", @@ -53,6 +52,7 @@ "next-auth": "^4.22.1", "next-axiom": "^1.9.2", "next-themes": "^0.3.0", + "nodemailer": "^6.10.1", "pg": "^8.11.1", "postcss": "^8.5.6", "react": "18.3.1", @@ -74,6 +74,7 @@ "cypress": "^14.5.3", "cypress-social-logins": "^1.14.1", "dotenv": "^16.3.1", + "drizzle-kit": "^0.31.5", "eslint": "^8.57.1", "prettier": "^3.6.2", "start-server-and-test": "^2.0.0" diff --git a/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts b/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts new file mode 100644 index 0000000..a61c3e3 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts @@ -0,0 +1,276 @@ +import { eq, and } from "drizzle-orm"; +import type { + Adapter, + AdapterUser, + AdapterAccount, + VerificationToken, +} from "next-auth/adapters"; + +import { + user as userTable, + accounts as accountsTable, + sessions as sessionsTable, + verificationTokens as verificationTokensTable, +} from "@/app/db/schema/users"; + +/** + * Custom Drizzle adapter for NextAuth v4 + * --------------------------------------- + * ✅ Works with bigint user IDs (no need for UUID migration) + * ✅ Compatible with existing users table + * ✅ Adds optional Database Session support + * + * By default, NextAuth uses JWT-based sessions (stateless). + * The session functions here are only used if you enable: + * + * session: { strategy: "database" } + * + * Benefits of database sessions: + * - Revocable sessions (logout from all devices) + * - View active sessions per user + * - Force re-login after role/permission changes + * - Audit trail of login activity + */ +export default function DrizzleEmailAdapter( + db: any, + tables: { + user: typeof userTable; + accounts: typeof accountsTable; + verificationTokens: typeof verificationTokensTable; + sessions?: typeof sessionsTable; + } +): Adapter { + const { user, accounts, verificationTokens, sessions } = tables; + + //---------------------------------------------------------------------- + // Helpers + //---------------------------------------------------------------------- + const normaliseEmail = (email: string) => email.trim().toLowerCase(); + + const toAdapterUser = (u: any): AdapterUser => ({ + id: String(u.id), + dbId: String(u.id), + email: u.email, + name: u.firstName ?? null, + image: u.image ?? null, + emailVerified: u.emailVerified ?? null, + }); + + //---------------------------------------------------------------------- + // Adapter methods + //---------------------------------------------------------------------- + return { + //------------------------------------------------------------------ + // USERS + //------------------------------------------------------------------ + async createUser( + newUser: Omit & { id?: string } + ): Promise { + const [created] = await db + .insert(user) // <-- now clearly the table + .values({ + email: normaliseEmail(newUser.email!), + firstName: newUser.name ?? null, + image: newUser.image ?? null, + emailVerified: newUser.emailVerified ?? null, + }) + .returning(); + + return toAdapterUser(created); + }, + + async getUser(id: string): Promise { + const [found] = await db + .select() + .from(user) + .where(eq(user.id, BigInt(id))); + return found ? toAdapterUser(found) : null; + }, + + async getUserByEmail(email: string): Promise { + const [found] = await db + .select() + .from(user) + .where(eq(user.email, normaliseEmail(email))); + return found ? toAdapterUser(found) : null; + }, + + async updateUser( + u: Partial & Pick + ): Promise { + const [updated] = await db + .update(user) + .set({ + firstName: u.name ?? null, + image: u.image ?? null, + emailVerified: u.emailVerified ?? null, + }) + .where(eq(user.id, BigInt(u.id))) + .returning(); + + return toAdapterUser(updated); + }, + async deleteUser(id: string): Promise { + await db.delete(user).where(eq(user.id, BigInt(id))); + }, + + //------------------------------------------------------------------ + // ACCOUNTS (OAuth) + //------------------------------------------------------------------ + async linkAccount(account: AdapterAccount): Promise { + try { + await db.insert(accounts).values({ + userId: BigInt(account.userId), + provider: account.provider, + providerAccountId: account.providerAccountId, + type: account.type, + refresh_token: account.refresh_token ?? null, + access_token: account.access_token ?? null, + expires_at: account.expires_at ?? null, + token_type: account.token_type ?? null, + scope: account.scope ?? null, + id_token: account.id_token ?? null, + session_state: account.session_state ?? null, + }); + } catch (err: any) { + console.error("Error linking account:", err); + throw err; + } + }, + + async unlinkAccount(params: { + provider: string; + providerAccountId: string; + }): Promise { + await db + .delete(accounts) + .where( + and( + eq(accounts.provider, params.provider), + eq(accounts.providerAccountId, params.providerAccountId) + ) + ); + }, + + async getUserByAccount(params: { + provider: string; + providerAccountId: string; + }): Promise { + const [acc] = await db + .select() + .from(accounts) + .where( + and( + eq(accounts.provider, params.provider), + eq(accounts.providerAccountId, params.providerAccountId) + ) + ); + + if (!acc) return null; + + const [usr] = await db + .select() + .from(user) + .where(eq(user.id, BigInt(acc.userId))); + return usr ? toAdapterUser(usr) : null; + }, + + //------------------------------------------------------------------ + // EMAIL VERIFICATION TOKENS + //------------------------------------------------------------------ + async createVerificationToken( + token: VerificationToken + ): Promise { + const [created] = await db + .insert(verificationTokens) + .values({ + ...token, + expires: new Date(token.expires), // keep as Date + }) + .returning(); + return created as VerificationToken; + }, + + async useVerificationToken(params: { + identifier: string; + token: string; + }): Promise { + const [found] = await db + .select() + .from(verificationTokens) + .where( + and( + eq(verificationTokens.identifier, params.identifier), + eq(verificationTokens.token, params.token) + ) + ); + + if (!found) return null; + + await db + .delete(verificationTokens) + .where( + and( + eq(verificationTokens.identifier, params.identifier), + eq(verificationTokens.token, params.token) + ) + ); + + return found as VerificationToken; + }, + + //------------------------------------------------------------------ + // SESSIONS (Optional – only used if session.strategy = "database") + //------------------------------------------------------------------ + async createSession(session) { + if (!sessions) return null; + const [created] = await db + .insert(sessions) + .values({ + sessionToken: session.sessionToken, + userId: BigInt(session.userId), + expires: session.expires, + }) + .returning(); + return created; + }, + + async getSessionAndUser(sessionToken) { + if (!sessions) return null; + + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.sessionToken, sessionToken)); + + if (!session) return null; + + const [u] = await db + .select() + .from(user) + .where(eq(user.id, BigInt(session.userId))); + + if (!u) return null; + + return { + session, + user: toAdapterUser(u), + }; + }, + + async updateSession(session) { + if (!sessions) return null; + const [updated] = await db + .update(sessions) + .set({ expires: session.expires }) + .where(eq(sessions.sessionToken, session.sessionToken)) + .returning(); + return updated ?? null; + }, + + async deleteSession(sessionToken) { + if (!sessions) return; + await db.delete(sessions).where(eq(sessions.sessionToken, sessionToken)); + }, + }; +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 8cc6019..ac41de6 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,29 +1,48 @@ import NextAuth, { NextAuthOptions } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c"; -import CredentialsProvider from "next-auth/providers/credentials"; +import EmailProvider from "next-auth/providers/email"; +import DrizzleEmailAdapter from "./DrizzleEmailAdapter"; import { db } from "@/app/db/db"; -import { user as userTable, User } from "@/app/db/schema/users"; +import { + user as users, + accounts, + verificationTokens, +} from "@/app/db/schema/users"; import { eq } from "drizzle-orm"; -const { GOOGLE_CLIENT_ID = "", GOOGLE_CLIENT_SECRET = "" } = process.env; +// ------------------------------------------------------------------ +// 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"; - -// TODO: handle token expiration -// https://next-auth.js.org/v3/tutorials/refresh-token-rotation -// propertly set options too -// https://next-auth.js.org/configuration/options +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, @@ -35,6 +54,8 @@ export const AuthOptions: NextAuthOptions = { }, }, }), + + // ------------------ Azure AD B2C ------------------ AzureADB2CProvider({ tenantId: AZURE_AD_B2C_TENANT_NAME, clientId: AZURE_AD_B2C_CLIENT_ID, @@ -47,109 +68,124 @@ export const AuthOptions: NextAuthOptions = { }, }, }), - CredentialsProvider({ - name: "Email Login", - credentials: { - email: { - label: "Email", - type: "email", + + // ------------------ Email (SES Magic Link) ------------------ + EmailProvider({ + server: { + host: EMAIL_SERVER_HOST, + port: Number(EMAIL_SERVER_PORT), + auth: { + user: EMAIL_SERVER_USER, + pass: EMAIL_SERVER_PASSWORD, }, }, - 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; - }, + from: EMAIL_FROM, + maxAge: 60 * 60, // magic link valid for 1 hour }), ], + + // ------------------------------------------------------------------ + // Pages + // ------------------------------------------------------------------ pages: { - signIn: "/", + 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 }) { try { - if (user === null || user.email === null) { - return "/beta"; - } - const dbUser: User[] = await db - .select() - .from(userTable) - .where(eq(userTable.email, String(user.email))); + if (!user?.email) return false; - if (dbUser.length > 1) { - console.error(`Multiple users found with email ${user.email}`); + 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)); + + if (!dbUser) { + console.warn(`User not found for ${normalisedEmail} after sign-in.`); return false; } - if (dbUser.length === 0 || account === null) { - return "/beta"; - } - - if (!dbUser[0].oauthId) { - // We make a second query to populate the oauthId and oauthProvider - console.log("Updating user with oauthId and oauthProvider"); + // Link OAuth ID if missing (helps for older accounts) + if (account && !dbUser.oauthId) { const provider = account.provider as OauthProvider; - await db - .update(userTable) + .update(users) .set({ oauthId: user.id, oauthProvider: provider }) - .where(eq(userTable.email, String(user.email))); - - console.log("Updated oauthId and oauthProvider"); + .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(); + + // If the user isn't onboarded yet, redirect to onboarding + if (!dbUser.onboarded) { + return "/onboarding"; } - // Set the user's ID from your database - // Because bigint isn't serializable, we need to convert it to a string - user.dbId = dbUser[0].id.toString(); return true; } catch (error) { - console.error("Error during sign-in: ", 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 }) { - // This is executed whenever a JWT is created or refreshed. - // `user` is the object returned from `signIn` callback and - // is only available during sign in, which is why we need to - // store the id in the token and then read it back into the session. if (user?.dbId) { token.dbId = user.dbId; } return token; }, + + /** + * Attach dbId to session.user + */ async session({ session, token }) { - if (session?.user) { + if (session.user && token.dbId) { session.user.dbId = token.dbId; } - return session; }, + + /** + * Redirect users after login + */ async redirect({ baseUrl }) { - const redirectUrl = baseUrl + "/home"; - return redirectUrl; + return `${baseUrl}/home`; }, }, + + // ------------------------------------------------------------------ + // General options + // ------------------------------------------------------------------ + session: { + strategy: "jwt", // lightweight, avoids DB session writes + }, + jwt: { + maxAge: 60 * 60 * 24 * 30, // 30 days + }, + debug: false, }; const handler = NextAuth(AuthOptions); - export { handler as GET, handler as POST }; diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts index 7a90192..309fee6 100644 --- a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -1,6 +1,6 @@ import { db } from "@/app/db/db"; import { NextRequest, NextResponse } from "next/server"; -import { portfolio, portfolioUsers} from "@/app/db/schema/portfolio"; +import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; import { recommendation, @@ -18,7 +18,6 @@ import { eq, inArray, Name } from "drizzle-orm"; import { z } from "zod"; import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; - // Get colloborators (users) that have access to the portfolio export async function GET( _req: NextRequest, @@ -41,7 +40,7 @@ export async function GET( // Explicitly normalize BigInts to strings const collaborators = rows.map((r) => ({ - portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString(): null, + portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null, userId: r.userId ? r.userId.toString() : null, role: r.role, name: r.name ?? null, @@ -58,7 +57,6 @@ export async function GET( } } - // PUT: update a collaborator’s role export async function PUT( req: NextRequest, @@ -110,7 +108,7 @@ export async function POST( const bodySchema = z.object({ email: z.string().email(), role: z.enum(ROLE_OPTIONS), - name: z.string() + name: z.string(), }); let body: z.infer; @@ -138,10 +136,14 @@ export async function POST( // If you’re on Postgres, this is ideal: const inserted = await db .insert(user) - .values({ email: body.email, firstName: body.name, oauthProvider: "credentials" }) + .values({ + email: body.email, + firstName: body.name, + oauthProvider: "credentials", + }) .onConflictDoNothing() // relies on a UNIQUE(email) constraint .returning({ id: user.id }); - + if (inserted.length > 0) { createdUserId = inserted[0].id; } else { @@ -202,4 +204,4 @@ export async function POST( { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/app/auth/verify-request/page.tsx b/src/app/auth/verify-request/page.tsx new file mode 100644 index 0000000..732fade --- /dev/null +++ b/src/app/auth/verify-request/page.tsx @@ -0,0 +1,14 @@ +export default function VerifyRequestPage() { + return ( +
+
+

+ Check your email +

+

+ We’ve sent you a sign-in link. Click it to finish logging in. +

+
+
+ ); +} diff --git a/src/app/beta/page.tsx b/src/app/beta/page.tsx deleted file mode 100644 index 14afc45..0000000 --- a/src/app/beta/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Beta() { - return
You do not have access to this application currently
; -} diff --git a/src/app/components/signin/CredentialsButton.tsx b/src/app/components/signin/EmailSignInButton.tsx similarity index 61% rename from src/app/components/signin/CredentialsButton.tsx rename to src/app/components/signin/EmailSignInButton.tsx index 0104c68..348c840 100644 --- a/src/app/components/signin/CredentialsButton.tsx +++ b/src/app/components/signin/EmailSignInButton.tsx @@ -13,17 +13,20 @@ export default function EmailSignInButton({ }) { const [email, setEmail] = useState(""); const [error, setError] = useState(initialError); + const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle"); const handleSubmit = async (e: { preventDefault: () => void }) => { e.preventDefault(); - const res = await signIn("credentials", { - email, - }); + setStatus("sending"); + + const res = await signIn("email", { email, redirect: false }); if (res?.error) { setError("You are not a valid user."); + setStatus("idle"); } else { - console.log("Login successful"); + setError(undefined); + setStatus("sent"); } }; @@ -31,40 +34,42 @@ export default function EmailSignInButton({ target: { value: SetStateAction }; }) => { setEmail(e.target.value); - if (error) { - setError(undefined); // Clear the error when the user starts typing - } + if (error) setError(undefined); }; - // Sync initial error state with server-side error prop - useEffect(() => { - setError(initialError); - }, [initialError]); + // Keep server-side error synced + 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.

} + {error &&

{error}

} + {status === "sent" && ( +

+ A login link has been sent to your email. +

+ )} + {status === "sending" && ( +

Sending login link...

+ )}
); diff --git a/src/app/db/migrations/0117_colossal_bastion.sql b/src/app/db/migrations/0117_colossal_bastion.sql new file mode 100644 index 0000000..b0eae14 --- /dev/null +++ b/src/app/db/migrations/0117_colossal_bastion.sql @@ -0,0 +1,35 @@ +CREATE TABLE "account" ( + "userId" bigint NOT NULL, + "type" text NOT NULL, + "provider" text NOT NULL, + "providerAccountId" text NOT NULL, + "refresh_token" text, + "access_token" text, + "expires_at" integer, + "token_type" text, + "scope" text, + "id_token" text, + "session_state" text, + CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId") +); +--> statement-breakpoint +CREATE TABLE "session" ( + "sessionToken" text PRIMARY KEY NOT NULL, + "userId" bigint NOT NULL, + "expires" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "verificationToken" ( + "identifier" text NOT NULL, + "token" text NOT NULL, + "expires" timestamp NOT NULL, + CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token") +); +--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "emailVerified" timestamp;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "image" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "onboarded" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "last_login" timestamp;--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email"); \ No newline at end of file diff --git a/src/app/db/migrations/meta/0117_snapshot.json b/src/app/db/migrations/meta/0117_snapshot.json new file mode 100644 index 0000000..10f3332 --- /dev/null +++ b/src/app/db/migrations/meta/0117_snapshot.json @@ -0,0 +1,3587 @@ +{ + "id": "a327a64c-a85a-408a-b460-8e53f65963d6", + "prevId": "c10c8ac5-aedc-411b-8907-a286c4c35540", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.energy_assessments": { + "name": "energy_assessments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uprn_source": { + "name": "uprn_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_reference_number": { + "name": "building_reference_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_energy_efficiency": { + "name": "current_energy_efficiency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_energy_rating": { + "name": "current_energy_rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address1": { + "name": "address1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address2": { + "name": "address2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address3": { + "name": "address3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posttown": { + "name": "posttown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency": { + "name": "constituency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency_label": { + "name": "constituency_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "low_energy_fixed_light_count": { + "name": "low_energy_fixed_light_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "construction_age_band": { + "name": "construction_age_band", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheat_energy_eff": { + "name": "mainheat_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_env_eff": { + "name": "windows_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_energy_eff": { + "name": "lighting_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_impact_potential": { + "name": "environment_impact_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatcont_description": { + "name": "mainheatcont_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheating_energy_eff": { + "name": "sheating_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_authority": { + "name": "local_authority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_authority_label": { + "name": "local_authority_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fixed_lighting_outlets_count": { + "name": "fixed_lighting_outlets_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_tariff": { + "name": "energy_tariff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mechanical_ventilation": { + "name": "mechanical_ventilation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "solar_water_heating_flag": { + "name": "solar_water_heating_flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emissions_potential": { + "name": "co2_emissions_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_heated_rooms": { + "name": "number_heated_rooms", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_description": { + "name": "floor_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_consumption_potential": { + "name": "energy_consumption_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "built_form": { + "name": "built_form", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_open_fireplaces": { + "name": "number_open_fireplaces", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_description": { + "name": "windows_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "glazed_area": { + "name": "glazed_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inspection_date": { + "name": "inspection_date", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "mains_gas_flag": { + "name": "mains_gas_flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emiss_curr_per_floor_area": { + "name": "co2_emiss_curr_per_floor_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heat_loss_corridor": { + "name": "heat_loss_corridor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unheated_corridor_length": { + "name": "unheated_corridor_length", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flat_storey_count": { + "name": "flat_storey_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roof_energy_eff": { + "name": "roof_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_floor_area": { + "name": "total_floor_area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_impact_current": { + "name": "environment_impact_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roof_description": { + "name": "roof_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_energy_eff": { + "name": "floor_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_habitable_rooms": { + "name": "number_habitable_rooms", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_env_eff": { + "name": "hot_water_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatc_energy_eff": { + "name": "mainheatc_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "main_fuel": { + "name": "main_fuel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_env_eff": { + "name": "lighting_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windows_energy_eff": { + "name": "windows_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_env_eff": { + "name": "floor_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheating_env_eff": { + "name": "sheating_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_description": { + "name": "lighting_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roof_env_eff": { + "name": "roof_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_energy_eff": { + "name": "walls_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_supply": { + "name": "photo_supply", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_cost_potential": { + "name": "lighting_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheat_env_eff": { + "name": "mainheat_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "multi_glaze_proportion": { + "name": "multi_glaze_proportion", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "main_heating_controls": { + "name": "main_heating_controls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flat_top_storey": { + "name": "flat_top_storey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secondheat_description": { + "name": "secondheat_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_env_eff": { + "name": "walls_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension_count": { + "name": "extension_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mainheatc_env_eff": { + "name": "mainheatc_env_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lmk_key": { + "name": "lmk_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wind_turbine_count": { + "name": "wind_turbine_count", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenure": { + "name": "tenure", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_level": { + "name": "floor_level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "potential_energy_efficiency": { + "name": "potential_energy_efficiency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "potential_energy_rating": { + "name": "potential_energy_rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_energy_eff": { + "name": "hot_water_energy_eff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "low_energy_lighting": { + "name": "low_energy_lighting", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "walls_description": { + "name": "walls_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hotwater_description": { + "name": "hotwater_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "co2_emissions_current": { + "name": "co2_emissions_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heating_cost_current": { + "name": "heating_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heating_cost_potential": { + "name": "heating_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_cost_current": { + "name": "hot_water_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hot_water_cost_potential": { + "name": "hot_water_cost_potential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lighting_cost_current": { + "name": "lighting_cost_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_consumption_current": { + "name": "energy_consumption_current", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lodgement_date": { + "name": "lodgement_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "lodgement_datetime": { + "name": "lodgement_datetime", + "type": "timestamp (6)", + "primaryKey": false, + "notNull": true + }, + "mainheat_description": { + "name": "mainheat_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "floor_height": { + "name": "floor_height", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "glazed_type": { + "name": "glazed_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_location": { + "name": "file_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surveyor_name": { + "name": "surveyor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surveyor_company": { + "name": "surveyor_company", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "space_heating_kwh": { + "name": "space_heating_kwh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "water_heating_kwh": { + "name": "water_heating_kwh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number_of_doors": { + "name": "number_of_doors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number_of_insulated_doors": { + "name": "number_of_insulated_doors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number_of_floors": { + "name": "number_of_floors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "insulation_wall_area": { + "name": "insulation_wall_area", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "heat_loss_perimeter": { + "name": "heat_loss_perimeter", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "party_wall_length": { + "name": "party_wall_length", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "perimeter": { + "name": "perimeter", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "rooms_with_bath_and_or_shower": { + "name": "rooms_with_bath_and_or_shower", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rooms_with_mixer_shower_no_bath": { + "name": "rooms_with_mixer_shower_no_bath", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "room_with_bath_and_mixer_shower": { + "name": "room_with_bath_and_mixer_shower", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent_draftproofed": { + "name": "percent_draftproofed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_hot_water_cylinder": { + "name": "has_hot_water_cylinder", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cylinder_insulation_type": { + "name": "cylinder_insulation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cylinder_insulation_thickness": { + "name": "cylinder_insulation_thickness", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cylinder_thermostat": { + "name": "cylinder_thermostat", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "main_dwelling_ground_floor_area": { + "name": "main_dwelling_ground_floor_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_windows": { + "name": "number_of_windows", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "windows_area": { + "name": "windows_area", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.energy_assessment_documents": { + "name": "energy_assessment_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "energy_assessment_id": { + "name": "energy_assessment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "document_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "document_location": { + "name": "document_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scenario_id": { + "name": "scenario_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk": { + "name": "energy_assessment_documents_energy_assessment_id_energy_assessments_id_fk", + "tableFrom": "energy_assessment_documents", + "tableTo": "energy_assessments", + "columnsFrom": [ + "energy_assessment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk": { + "name": "energy_assessment_documents_scenario_id_energy_assessment_scenarios_id_fk", + "tableFrom": "energy_assessment_documents", + "tableTo": "energy_assessment_scenarios", + "columnsFrom": [ + "scenario_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.energy_assessment_scenarios": { + "name": "energy_assessment_scenarios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "scenario_name": { + "name": "scenario_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_assessment_id": { + "name": "energy_assessment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk": { + "name": "energy_assessment_scenarios_energy_assessment_id_energy_assessments_id_fk", + "tableFrom": "energy_assessment_scenarios", + "tableTo": "energy_assessments", + "columnsFrom": [ + "energy_assessment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_package": { + "name": "funding_package", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scheme": { + "name": "scheme", + "type": "scheme", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "project_funding": { + "name": "project_funding", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_uplift": { + "name": "total_uplift", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "full_project_score": { + "name": "full_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "partial_project_score": { + "name": "partial_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "uplift_project_score": { + "name": "uplift_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "funding_package_plan_id_plan_id_fk": { + "name": "funding_package_plan_id_plan_id_fk", + "tableFrom": "funding_package", + "tableTo": "plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_package_measures": { + "name": "funding_package_measures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "funding_package_id": { + "name": "funding_package_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "measure": { + "name": "measure", + "type": "type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "innovation_uplift": { + "name": "innovation_uplift", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "partial_project_score": { + "name": "partial_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "uplift_project_score": { + "name": "uplift_project_score", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "funding_package_measures_funding_package_id_funding_package_id_fk": { + "name": "funding_package_measures_funding_package_id_funding_package_id_fk", + "tableFrom": "funding_package_measures", + "tableTo": "funding_package", + "columnsFrom": [ + "funding_package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "funding_package_measures_material_id_material_id_fk": { + "name": "funding_package_measures_material_id_material_id_fk", + "tableFrom": "funding_package_measures", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material": { + "name": "material", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "depth": { + "name": "depth", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "depth_unit": { + "name": "depth_unit", + "type": "depth_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cost_unit": { + "name": "cost_unit", + "type": "cost_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "r_value_per_mm": { + "name": "r_value_per_mm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "r_value_unit": { + "name": "r_value_unit", + "type": "r_value_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "thermal_conductivity": { + "name": "thermal_conductivity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "thermal_conductivity_unit": { + "name": "thermal_conductivity_unit", + "type": "thermal_conductivity_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "prime_material_cost": { + "name": "prime_material_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "material_cost": { + "name": "material_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_cost": { + "name": "labour_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_hours_per_unit": { + "name": "labour_hours_per_unit", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "plant_cost": { + "name": "plant_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_installer_quote": { + "name": "is_installer_quote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "innovation_rate": { + "name": "innovation_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "size": { + "name": "size", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "size_unit": { + "name": "size_unit", + "type": "size_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "includes_scaffolding": { + "name": "includes_scaffolding", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "includes_battery": { + "name": "includes_battery", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "battery_size": { + "name": "battery_size", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio": { + "name": "portfolio", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "budget": { + "name": "budget", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "goal", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_properties": { + "name": "number_of_properties", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rental_yield_increase": { + "name": "rental_yield_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "epc_breakdown_pre_retrofit": { + "name": "epc_breakdown_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_post_retrofit": { + "name": "epc_breakdown_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "n_units_to_retrofit": { + "name": "n_units_to_retrofit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_pre_retrofit": { + "name": "co2_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_post_retrofit": { + "name": "co2_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_pre_retrofit": { + "name": "energy_bill_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_post_retrofit": { + "name": "energy_bill_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_pre_retrofit": { + "name": "energy_consumption_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_post_retrofit": { + "name": "energy_consumption_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_improvement_per_unit": { + "name": "valuation_improvement_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_unit": { + "name": "cost_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_co2_saved": { + "name": "cost_per_co2_saved", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_sap_point": { + "name": "cost_per_sap_point", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_return_on_investment": { + "name": "valuation_return_on_investment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolioUsers": { + "name": "portfolioUsers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolioUsers_user_id_user_id_fk": { + "name": "portfolioUsers_user_id_user_id_fk", + "tableFrom": "portfolioUsers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "portfolioUsers_portfolio_id_portfolio_id_fk": { + "name": "portfolioUsers_portfolio_id_portfolio_id_fk", + "tableFrom": "portfolioUsers", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.non_intrusive_survey": { + "name": "non_intrusive_survey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "survey_date": { + "name": "survey_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "surveyor": { + "name": "surveyor", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.non_intrusive_survey_notes": { + "name": "non_intrusive_survey_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "survey_id": { + "name": "survey_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk": { + "name": "non_intrusive_survey_notes_survey_id_non_intrusive_survey_id_fk", + "tableFrom": "non_intrusive_survey_notes", + "tableTo": "non_intrusive_survey", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property": { + "name": "property", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "creation_status": { + "name": "creation_status", + "type": "creation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "building_reference_number": { + "name": "building_reference_number", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postcode": { + "name": "postcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_pre_condition_report": { + "name": "has_pre_condition_report", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_recommendations": { + "name": "has_recommendations", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "built_form": { + "name": "built_form", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_authority": { + "name": "local_authority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constituency": { + "name": "constituency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number_of_rooms": { + "name": "number_of_rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year_built": { + "name": "year_built", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenure": { + "name": "tenure", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_epc_rating": { + "name": "current_epc_rating", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "current_sap_points": { + "name": "current_sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_valuation": { + "name": "current_valuation", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "property_portfolio_id_portfolio_id_fk": { + "name": "property_portfolio_id_portfolio_id_fk", + "tableFrom": "property", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_epc": { + "name": "property_details_epc", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "full_address": { + "name": "full_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_floor_area": { + "name": "total_floor_area", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "walls": { + "name": "walls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "walls_rating": { + "name": "walls_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "roof": { + "name": "roof", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roof_rating": { + "name": "roof_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "floor_rating": { + "name": "floor_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "windows": { + "name": "windows", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "windows_rating": { + "name": "windows_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "heating": { + "name": "heating", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_rating": { + "name": "heating_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "heating_controls": { + "name": "heating_controls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heating_controls_rating": { + "name": "heating_controls_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "hot_water": { + "name": "hot_water", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hot_water_rating": { + "name": "hot_water_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "lighting": { + "name": "lighting", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lighting_rating": { + "name": "lighting_rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "mainfuel": { + "name": "mainfuel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ventilation": { + "name": "ventilation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "solar_pv": { + "name": "solar_pv", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "solar_hot_water": { + "name": "solar_hot_water", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "wind_turbine": { + "name": "wind_turbine", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "floor_height": { + "name": "floor_height", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_heated_rooms": { + "name": "number_heated_rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heat_loss_corridor": { + "name": "heat_loss_corridor", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "unheated_corridor_length": { + "name": "unheated_corridor_length", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "number_of_open_fireplaces": { + "name": "number_of_open_fireplaces", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_extensions": { + "name": "number_of_extensions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_storeys": { + "name": "number_of_storeys", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mains_gas": { + "name": "mains_gas", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "energy_tariff": { + "name": "energy_tariff", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_energy_consumption": { + "name": "primary_energy_consumption", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_emissions": { + "name": "co2_emissions", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_energy_demand": { + "name": "current_energy_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "current_energy_demand_heating_hotwater": { + "name": "current_energy_demand_heating_hotwater", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "heating_cost_current": { + "name": "heating_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "hot_water_cost_current": { + "name": "hot_water_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "lighting_cost_current": { + "name": "lighting_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "appliances_cost_current": { + "name": "appliances_cost_current", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "gas_standing_charge": { + "name": "gas_standing_charge", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "electricity_standing_charge": { + "name": "electricity_standing_charge", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "property_details_epc_property_id_property_id_fk": { + "name": "property_details_epc_property_id_property_id_fk", + "tableFrom": "property_details_epc", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_details_epc_portfolio_id_portfolio_id_fk": { + "name": "property_details_epc_portfolio_id_portfolio_id_fk", + "tableFrom": "property_details_epc", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_meter": { + "name": "property_details_meter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "energy_supplier": { + "name": "energy_supplier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gas_supplier": { + "name": "gas_supplier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meter_reading_total": { + "name": "meter_reading_total", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "meter_reading_electricity": { + "name": "meter_reading_electricity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "meter_reading_gas": { + "name": "meter_reading_gas", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_details_spatial": { + "name": "property_details_spatial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "x_coordinate": { + "name": "x_coordinate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "y_coordinate": { + "name": "y_coordinate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "conservation_status": { + "name": "conservation_status", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_listed_building": { + "name": "is_listed_building", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_heritage_building": { + "name": "is_heritage_building", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_targets": { + "name": "property_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "epc": { + "name": "epc", + "type": "epc", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "heat_demand": { + "name": "heat_demand", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "property_targets_property_id_property_id_fk": { + "name": "property_targets_property_id_property_id_fk", + "tableFrom": "property_targets", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "property_targets_portfolio_id_portfolio_id_fk": { + "name": "property_targets_portfolio_id_portfolio_id_fk", + "tableFrom": "property_targets", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan": { + "name": "plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "valuation_increase_lower_bound": { + "name": "valuation_increase_lower_bound", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_increase_upper_bound": { + "name": "valuation_increase_upper_bound", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "valuation_increase_average": { + "name": "valuation_increase_average", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "plan_portfolio_id_portfolio_id_fk": { + "name": "plan_portfolio_id_portfolio_id_fk", + "tableFrom": "plan", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_property_id_property_id_fk": { + "name": "plan_property_id_property_id_fk", + "tableFrom": "plan", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_scenario_id_scenario_id_fk": { + "name": "plan_scenario_id_scenario_id_fk", + "tableFrom": "plan", + "tableTo": "scenario", + "columnsFrom": [ + "scenario_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan_recommendations": { + "name": "plan_recommendations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "recommendation_id": { + "name": "recommendation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "plan_recommendations_plan_id_plan_id_fk": { + "name": "plan_recommendations_plan_id_plan_id_fk", + "tableFrom": "plan_recommendations", + "tableTo": "plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "plan_recommendations_recommendation_id_recommendation_id_fk": { + "name": "plan_recommendations_recommendation_id_recommendation_id_fk", + "tableFrom": "plan_recommendations", + "tableTo": "recommendation", + "columnsFrom": [ + "recommendation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendation": { + "name": "recommendation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measure_type": { + "name": "measure_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "contingency_cost": { + "name": "contingency_cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "starting_u_value": { + "name": "starting_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "new_u_value": { + "name": "new_u_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "sap_points": { + "name": "sap_points", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "heat_demand": { + "name": "heat_demand", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "kwh_savings": { + "name": "kwh_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rental_yield_increase": { + "name": "rental_yield_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "already_installed": { + "name": "already_installed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "recommendation_property_id_property_id_fk": { + "name": "recommendation_property_id_property_id_fk", + "tableFrom": "recommendation", + "tableTo": "property", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendation_materials": { + "name": "recommendation_materials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "recommendation_id": { + "name": "recommendation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "depth": { + "name": "depth", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "quantity_unit": { + "name": "quantity_unit", + "type": "unit_quantity", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "recommendation_materials_recommendation_id_recommendation_id_fk": { + "name": "recommendation_materials_recommendation_id_recommendation_id_fk", + "tableFrom": "recommendation_materials", + "tableTo": "recommendation", + "columnsFrom": [ + "recommendation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "recommendation_materials_material_id_material_id_fk": { + "name": "recommendation_materials_material_id_material_id_fk", + "tableFrom": "recommendation_materials", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenario": { + "name": "scenario", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "budget": { + "name": "budget", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "housing_type": { + "name": "housing_type", + "type": "housing_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "goal", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "goal_value": { + "name": "goal_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ashp_cop": { + "name": "ashp_cop", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 2.8 + }, + "trigger_file_path": { + "name": "trigger_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "already_installed_file_path": { + "name": "already_installed_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patches_file_path": { + "name": "patches_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "non_invasive_recommendations_file_path": { + "name": "non_invasive_recommendations_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exclusions": { + "name": "exclusions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "multi_plan": { + "name": "multi_plan", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "contingency": { + "name": "contingency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "funding": { + "name": "funding", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total_work_hours": { + "name": "total_work_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_savings": { + "name": "energy_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "co2_equivalent_savings": { + "name": "co2_equivalent_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "energy_cost_savings": { + "name": "energy_cost_savings", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "property_valuation_increase": { + "name": "property_valuation_increase", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "labour_days": { + "name": "labour_days", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_pre_retrofit": { + "name": "epc_breakdown_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "epc_breakdown_post_retrofit": { + "name": "epc_breakdown_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number_of_properties": { + "name": "number_of_properties", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "n_units_to_retrofit": { + "name": "n_units_to_retrofit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_pre_retrofit": { + "name": "co2_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "co2_per_unit_post_retrofit": { + "name": "co2_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_pre_retrofit": { + "name": "energy_bill_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_bill_per_unit_post_retrofit": { + "name": "energy_bill_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_pre_retrofit": { + "name": "energy_consumption_per_unit_pre_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "energy_consumption_per_unit_post_retrofit": { + "name": "energy_consumption_per_unit_post_retrofit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_improvement_per_unit": { + "name": "valuation_improvement_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_unit": { + "name": "cost_per_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_co2_saved": { + "name": "cost_per_co2_saved", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_per_sap_point": { + "name": "cost_per_sap_point", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_return_on_investment": { + "name": "valuation_return_on_investment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "scenario_portfolio_id_portfolio_id_fk": { + "name": "scenario_portfolio_id_portfolio_id_fk", + "tableFrom": "scenario", + "tableTo": "portfolio", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.solar": { + "name": "solar", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "uprn": { + "name": "uprn", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "google_api_response": { + "name": "google_api_response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.solar_scenario": { + "name": "solar_scenario", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "solar_id": { + "name": "solar_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "scenario_type": { + "name": "scenario_type", + "type": "scenario_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "number_panels": { + "name": "number_panels", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "array_kwhp": { + "name": "array_kwhp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lifetime_dc_kwh": { + "name": "lifetime_dc_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "yearly_dc_kwh": { + "name": "yearly_dc_kwh", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "lifetime_ac_kwh": { + "name": "lifetime_ac_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "yearly_ac_kwh": { + "name": "yearly_ac_kwh", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "expected_payback_years": { + "name": "expected_payback_years", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "panelled_roof_area": { + "name": "panelled_roof_area", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "solar_scenario_solar_id_solar_id_fk": { + "name": "solar_scenario_solar_id_solar_id_fk", + "tableFrom": "solar_scenario", + "tableTo": "solar", + "columnsFrom": [ + "solar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "firstName": { + "name": "firstName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "oauth_id": { + "name": "oauth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login": { + "name": "last_login", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.document_type": { + "name": "document_type", + "schema": "public", + "values": [ + "EPR", + "Condition Report", + "Evidence Report", + "Summary Information", + "Floor Plan", + "Scenario Draft EPC", + "Scenario Site Notes" + ] + }, + "public.scheme": { + "name": "scheme", + "schema": "public", + "values": [ + "eco4", + "gbis", + "whlg", + "none" + ] + }, + "public.cost_unit": { + "name": "cost_unit", + "schema": "public", + "values": [ + "gbp_sq_meter", + "gbp_per_unit", + "gbp_per_m2", + "gbp_per_m" + ] + }, + "public.depth_unit": { + "name": "depth_unit", + "schema": "public", + "values": [ + "mm" + ] + }, + "public.type": { + "name": "type", + "schema": "public", + "values": [ + "suspended_floor_insulation", + "solid_floor_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "cavity_wall_insulation", + "mechanical_ventilation", + "loft_insulation", + "exposed_floor_insulation", + "flat_roof_insulation", + "room_roof_insulation", + "cavity_wall_extraction", + "iwi_wall_demolition", + "iwi_vapour_barrier", + "iwi_redecoration", + "suspended_floor_demolition", + "suspended_floor_redecoration", + "suspended_floor_vapour_barrier", + "solid_floor_demolition", + "solid_floor_preparation", + "solid_floor_vapour_barrier", + "solid_floor_redecoration", + "ewi_wall_demolition", + "ewi_wall_preparation", + "ewi_wall_redecoration", + "low_energy_lighting_installation", + "flat_roof_preparation", + "flat_roof_vapour_barrier", + "flat_roof_waterproofing", + "windows_glazing", + "trickle_vent", + "door_undercut", + "solar_pv", + "solar_battery", + "scaffolding", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "roomstat_programmer_trvs", + "time_temperature_zone_control", + "sealing_fireplace" + ] + }, + "public.r_value_unit": { + "name": "r_value_unit", + "schema": "public", + "values": [ + "square_meter_kelvin_per_watt" + ] + }, + "public.size_unit": { + "name": "size_unit", + "schema": "public", + "values": [ + "kWp", + "kW", + "watt", + "storey" + ] + }, + "public.thermal_conductivity_unit": { + "name": "thermal_conductivity_unit", + "schema": "public", + "values": [ + "watt_per_meter_kelvin" + ] + }, + "public.goal": { + "name": "goal", + "schema": "public", + "values": [ + "Valuation Improvement", + "Increasing EPC", + "Reducing CO2 emissions", + "Energy Savings", + "None" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "creator", + "admin", + "read", + "write" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "scoping", + "survey", + "assessment", + "tendering", + "project underway", + "completion; status: on track", + "completion; status: delayed", + "completion; status: at risk", + "completion; status: completed", + "needs review" + ] + }, + "public.epc": { + "name": "epc", + "schema": "public", + "values": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G" + ] + }, + "public.creation_status": { + "name": "creation_status", + "schema": "public", + "values": [ + "LOADING", + "READY", + "ERROR" + ] + }, + "public.housing_type": { + "name": "housing_type", + "schema": "public", + "values": [ + "Private", + "Social" + ] + }, + "public.unit_quantity": { + "name": "unit_quantity", + "schema": "public", + "values": [ + "m2", + "part", + "kwp" + ] + }, + "public.scenario_type": { + "name": "scenario_type", + "schema": "public", + "values": [ + "unit", + "building" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/app/db/migrations/meta/_journal.json b/src/app/db/migrations/meta/_journal.json index d9ffccc..c44a331 100644 --- a/src/app/db/migrations/meta/_journal.json +++ b/src/app/db/migrations/meta/_journal.json @@ -820,6 +820,13 @@ "when": 1759069966418, "tag": "0116_spotty_leech", "breakpoints": true + }, + { + "idx": 117, + "version": "7", + "when": 1760191704756, + "tag": "0117_colossal_bastion", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/db/schema/users.ts b/src/app/db/schema/users.ts index 372a678..ec57cec 100644 --- a/src/app/db/schema/users.ts +++ b/src/app/db/schema/users.ts @@ -1,27 +1,87 @@ -import { bigserial, text, timestamp, pgTable } from "drizzle-orm/pg-core"; +import { + bigint, + bigserial, + text, + timestamp, + pgTable, + primaryKey, + integer, + boolean, +} from "drizzle-orm/pg-core"; import { InferModel } from "drizzle-orm"; +// ------------------------- +// USERS +// ------------------------- export const user = pgTable("user", { id: bigserial("id", { mode: "bigint" }).primaryKey(), firstName: text("firstName"), - // At the moment, Drizzle doesn't support unique constraints - email: text("email").notNull(), + email: text("email").notNull().unique(), + emailVerified: timestamp("emailVerified", { mode: "date" }), oauthId: text("oauth_id"), - oauthProvider: text("oauth_provider").$type<"google" | "credentials">(), - // role: text("role").$type<"admin" | "write" | "read">(), - createdAt: timestamp("created_at", { - precision: 6, - withTimezone: true, - }) + oauthProvider: text("oauth_provider").$type< + "google" | "credentials" | "azure-ad-b2c" + >(), + image: text("image"), + onboarded: boolean("onboarded").default(false).notNull(), + lastLogin: timestamp("last_login", { mode: "date" }), + createdAt: timestamp("created_at", { precision: 6, withTimezone: true }) .defaultNow() .notNull(), - updatedAt: timestamp("updated_at", { - precision: 6, - withTimezone: true, - }) + updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true }) .defaultNow() .notNull(), }); +// ------------------------- +// ACCOUNTS (OAuth providers) +// ------------------------- +export const accounts = pgTable( + "account", + { + userId: bigint("userId", { mode: "bigint" }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + type: text("type").$type<"oauth" | "email" | "credentials">().notNull(), + provider: text("provider").notNull(), + providerAccountId: text("providerAccountId").notNull(), + refresh_token: text("refresh_token"), + access_token: text("access_token"), + expires_at: integer("expires_at"), + token_type: text("token_type"), + scope: text("scope"), + id_token: text("id_token"), + session_state: text("session_state"), + }, + (account) => [ + primaryKey({ columns: [account.provider, account.providerAccountId] }), + ] +); + +export const sessions = pgTable("session", { + sessionToken: text("sessionToken").primaryKey(), + userId: bigint("userId", { mode: "bigint" }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + expires: timestamp("expires", { mode: "date" }).notNull(), +}); + +export const verificationTokens = pgTable( + "verificationToken", + { + identifier: text("identifier").notNull(), + token: text("token").notNull(), + expires: timestamp("expires", { mode: "date" }).notNull(), + }, + (vt) => [primaryKey({ columns: [vt.identifier, vt.token] })] +); + +// ------------------------- +// Types +// ------------------------- export type User = InferModel; export type NewUser = InferModel; + +export type Account = InferModel; +export type Session = InferModel; +export type VerificationToken = InferModel; diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx new file mode 100644 index 0000000..48f8c8a --- /dev/null +++ b/src/app/onboarding/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Input } from "@/app/shadcn_components/ui/input"; + +export default function OnboardingPage() { + const router = useRouter(); + const [name, setName] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + await fetch("/api/user/onboard", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + router.push("/home"); + } + + return ( +
+
+

Welcome!

+

+ Let's complete your profile to get started. +

+ + setName(e.target.value)} + placeholder="Your full name" + required + /> + +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c1b987a..26962d2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,15 +2,13 @@ import { getServerSession } from "next-auth/next"; import { AuthOptions } from "./api/auth/[...nextauth]/route"; import GoogleSignInButton from "./components/signin/GoogleSignInButton"; import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton"; -import EmailSignInButton from "./components/signin/CredentialsButton"; +import EmailSignInButton from "./components/signin/EmailSignInButton"; import { redirect } from "next/navigation"; import Image from "next/image"; -export default async function Home( - props: { - searchParams: Promise<{ error?: string }>; - } -) { +export default async function Home(props: { + searchParams: Promise<{ error?: string }>; +}) { const searchParams = await props.searchParams; const session = await getServerSession(AuthOptions); From 2d4a11b94de2fd06484e72ffa5aba7a8f699bb41 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Oct 2025 18:49:06 +0000 Subject: [PATCH 2/8] login working --- .../auth/[...nextauth]/DrizzleEmailAdapter.ts | 5 +++ src/app/api/auth/[...nextauth]/route.ts | 15 +++---- src/app/auth/verify-request/page.tsx | 14 ------- .../components/signin/EmailSignInButton.tsx | 5 +++ src/middleware.ts | 42 ++++++++++++++++--- src/types/next-auth.d.ts | 1 + 6 files changed, 56 insertions(+), 26 deletions(-) delete mode 100644 src/app/auth/verify-request/page.tsx diff --git a/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts b/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts index a61c3e3..0e7cbab 100644 --- a/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts +++ b/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts @@ -31,6 +31,9 @@ import { * - Force re-login after role/permission changes * - Audit trail of login activity */ + +// We extend + export default function DrizzleEmailAdapter( db: any, tables: { @@ -54,6 +57,7 @@ export default function DrizzleEmailAdapter( name: u.firstName ?? null, image: u.image ?? null, emailVerified: u.emailVerified ?? null, + onboarded: u.onboarded ?? false, }); //---------------------------------------------------------------------- @@ -181,6 +185,7 @@ export default function DrizzleEmailAdapter( async createVerificationToken( token: VerificationToken ): Promise { + console.log("Creating verification token for:", token.identifier); const [created] = await db .insert(verificationTokens) .values({ diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index ac41de6..ce774a0 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -113,8 +113,8 @@ export const AuthOptions: NextAuthOptions = { .where(eq(users.email, normalisedEmail)); if (!dbUser) { - console.warn(`User not found for ${normalisedEmail} after sign-in.`); - return false; + console.log("New user sign up for email:", normalisedEmail); + return true; } // Link OAuth ID if missing (helps for older accounts) @@ -134,11 +134,7 @@ export const AuthOptions: NextAuthOptions = { // Pass bigint ID into NextAuth session/jwt user.dbId = dbUser.id.toString(); - - // If the user isn't onboarded yet, redirect to onboarding - if (!dbUser.onboarded) { - return "/onboarding"; - } + user.onboarded = dbUser.onboarded ?? false; return true; } catch (error) { @@ -154,6 +150,10 @@ export const AuthOptions: NextAuthOptions = { if (user?.dbId) { token.dbId = user.dbId; } + + // Fetch onboarding status from user + if (user?.onboarded !== undefined) token.onboarded = user.onboarded; + return token; }, @@ -171,6 +171,7 @@ export const AuthOptions: NextAuthOptions = { * Redirect users after login */ async redirect({ baseUrl }) { + // If the user has not onboarded, send them to onboarding return `${baseUrl}/home`; }, }, diff --git a/src/app/auth/verify-request/page.tsx b/src/app/auth/verify-request/page.tsx deleted file mode 100644 index 732fade..0000000 --- a/src/app/auth/verify-request/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export default function VerifyRequestPage() { - return ( -
-
-

- Check your email -

-

- We’ve sent you a sign-in link. Click it to finish logging in. -

-
-
- ); -} diff --git a/src/app/components/signin/EmailSignInButton.tsx b/src/app/components/signin/EmailSignInButton.tsx index 348c840..57a6c6c 100644 --- a/src/app/components/signin/EmailSignInButton.tsx +++ b/src/app/components/signin/EmailSignInButton.tsx @@ -19,12 +19,17 @@ export default function EmailSignInButton({ e.preventDefault(); setStatus("sending"); + console.log("BEFOERE SIGN IN"); + console.log("window.location.origin:", window.location.origin); const res = await signIn("email", { email, redirect: false }); + console.log("AFTER SIGN IN"); if (res?.error) { setError("You are not a valid user."); setStatus("idle"); + console.log("Error signing in:", res.error); } else { + console.log("Sign-in link sent to:", email); setError(undefined); setStatus("sent"); } diff --git a/src/middleware.ts b/src/middleware.ts index e8158c3..b22f90b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,12 +1,44 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; + +export async function middleware(req: NextRequest) { + const token = await getToken({ req }); + const { pathname } = req.nextUrl; + + // If no session, send user to sign-in page + if (!token) { + return NextResponse.redirect(new URL("/", req.url)); + } + + const userEmail = token.email || ""; + + // Internal users (bypass onboarding) + // const isInternal = userEmail.endsWith("@domna.homes"); + + // Not onboarded and not internal + if (token.onboarded === false && pathname !== "/onboarding") { + return NextResponse.redirect(new URL("/onboarding", req.url)); + } + + // Already onboarded but tries to go back to onboarding page + if (token.onboarded === true && pathname === "/onboarding") { + return NextResponse.redirect(new URL("/home", req.url)); + } + + // Everything else allowed + return NextResponse.next(); +} + export const config = { matcher: [ + // Protect only your app’s authenticated areas "/home/:path*", "/portfolio/:path*", "/search/:path*", - "/addresses/:path", - "/due-considerations/:path", - "/eco-spreadsheet/:path", + "/addresses/:path*", + "/due-considerations/:path*", + "/eco-spreadsheet/:path*", + "/onboarding", // add onboarding itself ], }; - -export { default } from "next-auth/middleware"; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 31c6bd6..259883a 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -10,5 +10,6 @@ declare module "next-auth" { } interface User { dbId: string; + onboarded: boolean; } } From cf28e5d9fc20fd9b5b9723a001ad654134579fc1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Oct 2025 20:27:17 +0000 Subject: [PATCH 3/8] onboarding wip --- src/app/db/schema/users.ts | 105 ++++++++++ src/app/onboarding/page.tsx | 253 +++++++++++++++++++++--- src/app/shadcn_components/ui/select.tsx | 38 ++-- 3 files changed, 350 insertions(+), 46 deletions(-) diff --git a/src/app/db/schema/users.ts b/src/app/db/schema/users.ts index ec57cec..0232d77 100644 --- a/src/app/db/schema/users.ts +++ b/src/app/db/schema/users.ts @@ -7,6 +7,9 @@ import { primaryKey, integer, boolean, + json, + pgEnum, + varchar, } from "drizzle-orm/pg-core"; import { InferModel } from "drizzle-orm"; @@ -76,6 +79,105 @@ export const verificationTokens = pgTable( (vt) => [primaryKey({ columns: [vt.identifier, vt.token] })] ); +export const UserType: [string, ...string[]] = [ + "private_landlord", + "private_tenant", + "social_landlord", + "social_tenant", + "homeowner", + "other", +]; + +export const PropertyCount: [string, ...string[]] = [ + // Private landlord options + "1", + "2–5", + "6–20", + "21+", + // Social landlord options + "1–50", + "51–100", + "101–300", + "301–1000", + "1000+", +]; + +export const ReferralSource: [string, ...string[]] = [ + "search", + "social_media", + "NRLA", + "partner", + "word_of_mouth", + "other", +]; + +export const Goal: [string, ...string[]] = [ + "access_funding", + "net_zero", + "improve_condition", + "save_money", + "other", +]; + +export const userTypeEnum = pgEnum("user_type", UserType); +export const propertyCountEnum = pgEnum("property_count", PropertyCount); +export const referralSourceEnum = pgEnum("referral_source", ReferralSource); +export const goalEnum = pgEnum("goal", Goal); + +// ---------------------------- +// MAIN TABLE +// ---------------------------- +export const userProfiles = pgTable("user_profiles", { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + + userId: bigint("user_id", { mode: "bigint" }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + + // Profile + userType: userTypeEnum("user_type").notNull(), + propertyCount: propertyCountEnum("property_count"), // Nullable for homeowners / tenants + + // Goals (multi-select) + goals: json("goals").$type<(typeof Goal)[number][]>(), + + // Referral + referralSource: referralSourceEnum("referral_source"), + nrlaMembershipId: varchar("nrla_membership_id", { length: 255 }), + + // Compliance + acceptedPrivacy: boolean("accepted_privacy").notNull().default(false), + acceptedPrivacyAt: timestamp("accepted_privacy_at", { + withTimezone: true, + precision: 6, + }), + + // Marketing + marketingOptIn: boolean("marketing_opt_in").default(false), + marketingOptInAt: timestamp("marketing_opt_in_at", { + withTimezone: true, + precision: 6, + }), + + // Basic user identity + firstName: text("first_name"), + lastName: text("last_name"), + + // Metadata + createdAt: timestamp("created_at", { + precision: 6, + withTimezone: true, + }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { + precision: 6, + withTimezone: true, + }) + .defaultNow() + .notNull(), +}); + // ------------------------- // Types // ------------------------- @@ -85,3 +187,6 @@ export type NewUser = InferModel; export type Account = InferModel; export type Session = InferModel; export type VerificationToken = InferModel; + +export type UserProfile = InferModel; +export type NewUserProfile = InferModel; diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 48f8c8a..cb255c7 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -2,48 +2,247 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@/app/shadcn_components/ui/button"; import { Input } from "@/app/shadcn_components/ui/input"; +import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; +import { + Select, + SelectTrigger, + SelectItem, + SelectContent, +} from "@/app/shadcn_components/ui/select"; + +const OnboardingSchema = z.object({ + firstName: z.string().min(1, "Required"), + lastName: z.string().min(1, "Required"), + userType: z.enum([ + "private_landlord", + "private_tenant", + "social_landlord", + "social_tenant", + "homeowner", + "other", + ]), + propertyCount: z + .enum([ + "1", + "2–5", + "6–20", + "21+", + "1–50", + "51–100", + "101–300", + "301–1000", + "1000+", + ]) + .nullable() + .optional(), + goals: z.array( + z.enum([ + "access_funding", + "net_zero", + "improve_condition", + "save_money", + "other", + ]) + ), + referralSource: z.enum([ + "search", + "social_media", + "NRLA", + "partner", + "word_of_mouth", + "other", + ]), + nrlaMembershipId: z.string().optional(), + acceptedPrivacy: z.boolean().refine((v) => v === true, { + message: "You must accept the privacy policy", + }), + marketingOptIn: z.boolean().optional(), +}); + +type OnboardingData = z.infer; export default function OnboardingPage() { const router = useRouter(); - const [name, setName] = useState(""); + const [step, setStep] = useState(1); - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + const form = useForm({ + resolver: zodResolver(OnboardingSchema), + defaultValues: { + goals: [], + marketingOptIn: false, + acceptedPrivacy: false, + }, + }); + async function handleSubmit(data: OnboardingData) { await fetch("/api/user/onboard", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify(data), }); - router.push("/home"); } - return ( -
-
-

Welcome!

-

- Let's complete your profile to get started. -

+ const { register, handleSubmit: submit, watch, setValue } = form; + const userType = watch("userType"); + const referralSource = watch("referralSource"); - setName(e.target.value)} - placeholder="Your full name" - required - /> - + return ( +
+ +

+ Step {step} of 3 +

+ + {step === 1 && ( +
+ + + {(userType === "private_landlord" || + userType === "social_landlord") && ( + + )} +
+ )} + + {step === 2 && ( +
+

+ What are your main goals? +

+ {[ + "access_funding", + "net_zero", + "improve_condition", + "save_money", + "other", + ].map((goal) => ( + + ))} + + + + {referralSource === "NRLA" && ( + + )} +
+ )} + + {step === 3 && ( +
+ + + + + + +
+ )} + +
+ {step > 1 && ( + + )} + {step < 3 ? ( + + ) : ( + + )} +
); diff --git a/src/app/shadcn_components/ui/select.tsx b/src/app/shadcn_components/ui/select.tsx index 16429d6..e25172f 100644 --- a/src/app/shadcn_components/ui/select.tsx +++ b/src/app/shadcn_components/ui/select.tsx @@ -1,16 +1,16 @@ -"use client" +"use client"; -import * as React from "react" -import * as SelectPrimitive from "@radix-ui/react-select" -import { Check, ChevronDown } from "lucide-react" +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Select = SelectPrimitive.Root +const Select = SelectPrimitive.Root; -const SelectGroup = SelectPrimitive.Group +const SelectGroup = SelectPrimitive.Group; -const SelectValue = SelectPrimitive.Value +const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< React.ElementRef, @@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef< -)) -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; const SelectContent = React.forwardRef< React.ElementRef, @@ -59,8 +59,8 @@ const SelectContent = React.forwardRef< -)) -SelectContent.displayName = SelectPrimitive.Content.displayName +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< React.ElementRef, @@ -71,8 +71,8 @@ const SelectLabel = React.forwardRef< className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} /> -)) -SelectLabel.displayName = SelectPrimitive.Label.displayName +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< React.ElementRef, @@ -94,8 +94,8 @@ const SelectItem = React.forwardRef< {children} -)) -SelectItem.displayName = SelectPrimitive.Item.displayName +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef< React.ElementRef, @@ -106,8 +106,8 @@ const SelectSeparator = React.forwardRef< className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} /> -)) -SelectSeparator.displayName = SelectPrimitive.Separator.displayName +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; export { Select, @@ -118,4 +118,4 @@ export { SelectLabel, SelectItem, SelectSeparator, -} +}; From ef24c8f77343fc902a951bd06c586e67902337e5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Oct 2025 12:57:52 +0000 Subject: [PATCH 4/8] onboarding ui almost complete --- package-lock.json | 549 +++------------------ package.json | 2 +- src/app/onboarding/page.tsx | 612 +++++++++++++++++------- src/app/shadcn_components/ui/select.tsx | 65 ++- tailwind.config.js | 445 ++++++++--------- 5 files changed, 767 insertions(+), 906 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0f037b..75fd95e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.1.3", "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^1.2.2", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", @@ -2841,13 +2841,10 @@ } }, "node_modules/@radix-ui/number": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", - "integrity": "sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - } + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" }, "node_modules/@radix-ui/primitive": { "version": "1.1.2", @@ -3593,12 +3590,6 @@ } } }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "license": "MIT" - }, "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3630,39 +3621,38 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-1.2.2.tgz", - "integrity": "sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/number": "1.0.1", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-collection": "1.0.3", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-dismissable-layer": "1.0.4", - "@radix-ui/react-focus-guards": "1.0.1", - "@radix-ui/react-focus-scope": "1.0.3", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-popper": "1.1.2", - "@radix-ui/react-portal": "1.0.3", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-slot": "1.0.2", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-controllable-state": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1", - "@radix-ui/react-use-previous": "1.0.1", - "@radix-ui/react-visually-hidden": "1.0.3", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.5" + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -3674,137 +3664,28 @@ } }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", - "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-arrow": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", - "integrity": "sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-collection": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", - "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-slot": "1.0.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", - "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", - "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-direction": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", - "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz", - "integrity": "sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-escape-keydown": "1.0.3" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -3816,61 +3697,13 @@ } }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-guards": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", - "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-scope": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.3.tgz", - "integrity": "sha512-upXdPfqI4islj2CslyfUBNlaJCPybbqRHAi1KER7Isel9Q2AtSJ0zRBZv8mWQiFXD2nyAJ4BhC3yXgZ6kMBSrQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-id": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", - "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -3879,28 +3712,27 @@ } }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.2.tgz", - "integrity": "sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.0.3", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1", - "@radix-ui/react-use-rect": "1.0.1", - "@radix-ui/react-use-size": "1.0.1", - "@radix-ui/rect": "1.0.1" + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -3911,261 +3743,6 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.3.tgz", - "integrity": "sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", - "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", - "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", - "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", - "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", - "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", - "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-previous": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz", - "integrity": "sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-rect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz", - "integrity": "sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-size": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz", - "integrity": "sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", - "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/rect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", - "integrity": "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@radix-ui/react-select/node_modules/react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", diff --git a/package.json b/package.json index c07de30..8d51e90 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.1.3", "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^1.2.2", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index cb255c7..ef5aeae 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -11,55 +11,64 @@ import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; import { Select, SelectTrigger, - SelectItem, SelectContent, + SelectItem, } from "@/app/shadcn_components/ui/select"; +import { Fragment } from "react"; const OnboardingSchema = z.object({ - firstName: z.string().min(1, "Required"), - lastName: z.string().min(1, "Required"), - userType: z.enum([ - "private_landlord", - "private_tenant", - "social_landlord", - "social_tenant", - "homeowner", - "other", - ]), + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + userType: z.enum( + [ + "private_landlord", + "private_tenant", + "social_landlord", + "social_tenant", + "homeowner", + "other", + ], + { + required_error: "Please tell us who you are", + } + ), propertyCount: z - .enum([ - "1", - "2–5", - "6–20", - "21+", - "1–50", - "51–100", - "101–300", - "301–1000", - "1000+", - ]) + .enum( + [ + "1", + "2–5", + "6–20", + "21+", + "1–50", + "51–100", + "101–300", + "301–1000", + "1000+", + ], + { + required_error: "Please tell us how many homes you’re responsible for", + } + ) .nullable() .optional(), - goals: z.array( - z.enum([ - "access_funding", - "net_zero", - "improve_condition", - "save_money", - "other", - ]) + goals: z + .array( + z.enum([ + "access_funding", + "net_zero", + "improve_condition", + "save_money", + "other", + ]) + ) + .min(1, "Please select at least one goal"), + referralSource: z.enum( + ["search", "social_media", "NRLA", "partner", "word_of_mouth", "other"], + { required_error: "Please tell us how you heard about Domna" } ), - referralSource: z.enum([ - "search", - "social_media", - "NRLA", - "partner", - "word_of_mouth", - "other", - ]), nrlaMembershipId: z.string().optional(), acceptedPrivacy: z.boolean().refine((v) => v === true, { - message: "You must accept the privacy policy", + message: "You must accept our privacy policy to continue", }), marketingOptIn: z.boolean().optional(), }); @@ -77,9 +86,23 @@ export default function OnboardingPage() { marketingOptIn: false, acceptedPrivacy: false, }, + mode: "onChange", }); - async function handleSubmit(data: OnboardingData) { + const { + register, + handleSubmit, + control, + watch, + setValue, + trigger, + formState: { errors, isValid }, + } = form; + + const userType = watch("userType"); + const referralSource = watch("referralSource"); + + async function onSubmit(data: OnboardingData) { await fetch("/api/user/onboard", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -88,162 +111,383 @@ export default function OnboardingPage() { router.push("/home"); } - const { register, handleSubmit: submit, watch, setValue } = form; - const userType = watch("userType"); - const referralSource = watch("referralSource"); + async function nextStep() { + const valid = await trigger( + step === 1 + ? [ + "userType", + ...(userType?.includes("landlord") ? ["propertyCount"] : []), + ] + : step === 2 + ? ["goals", "referralSource"] + : [] + ); + if (valid) setStep((s) => Math.min(s + 1, 3)); + } + + function prevStep() { + setStep((s) => Math.max(s - 1, 1)); + } return ( -
-
-

- Step {step} of 3 -

+
+ {/* Left image panel */} +
+
- {step === 1 && ( -
- - - {(userType === "private_landlord" || - userType === "social_landlord") && ( - - )} -
- )} - - {step === 2 && ( -
-

- What are your main goals? +

+
+

Welcome to Domna IQ

+

+ Help us get to know you so we can tailor your experience.

+
+
+
+ + {/* Right section with journey and form */} +
+ {/* Domna Journey Progress */} +
+

+ Your retrofit journey with Domna +

+ + {/* Row 1: Nodes + Connectors */} +
{[ - "access_funding", - "net_zero", - "improve_condition", - "save_money", - "other", - ].map((goal) => ( - + { label: "Remote Portfolio Assessment", icon: "🧠", step: 1 }, + { label: "Survey & Design", icon: "📐", step: 2 }, + { label: "Installation & Funding Claim", icon: "🏡", step: 3 }, + ].map((stage, i, arr) => { + const active = step >= stage.step; + const showConnector = i < arr.length - 1; + const segmentFilled = step > stage.step; + + return ( + +
+
+ {stage.icon} +
+

+ {stage.label} +

+
+ + {showConnector && ( +
+
+
+
+ )} + + ); + })} +
+ {/* Row 2: Captions aligned under each node */} +
+ {[ + { + step: 1, + caption: + "IQ models home energy improvements, costs, and funding availability at the click of a button.", + }, + { + step: 2, + caption: + "Schedule a survey. Our team surveys properties and produces a tailored retrofit design.", + }, + { + step: 3, + caption: + "We guide installation and help you claim any available funding to complete your retrofit journey.", + }, + ].map(({ step: s, caption }) => ( +
+ {caption} +
))} +
+
- - - {referralSource === "NRLA" && ( - + {/* Progress indicator */} +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ +
+ {step === 1 && ( +
+

+ Step 1 of 3 +

+ ( + + )} + /> + + {errors.userType && ( +

+ {errors.userType.message} +

+ )} + + {(userType === "private_landlord" || + userType === "social_landlord") && ( + ( + + )} + /> + )} +
+ )} + + {step === 2 && ( +
+

+ Step 2 of 3 +

+

+ What would you like to achieve with Domna? + * +

+
+ {[ + "access_funding", + "net_zero", + "improve_condition", + "save_money", + "other", + ].map((g) => ( + + ))} +
+ + ( + + )} + /> + + {referralSource === "NRLA" && ( + + )} +
+ )} + + {step === 3 && ( +
+

+ Step 3 of 3 +

+ + + + + + +
)}
- )} - {step === 3 && ( -
- - - - - - + {/* Buttons fixed at bottom */} +
+ {step > 1 && ( + + )} + {step < 3 ? ( + + ) : ( + + )}
- )} - -
- {step > 1 && ( - - )} - {step < 3 ? ( - - ) : ( - - )} -
- + +
); } diff --git a/src/app/shadcn_components/ui/select.tsx b/src/app/shadcn_components/ui/select.tsx index e25172f..35292dc 100644 --- a/src/app/shadcn_components/ui/select.tsx +++ b/src/app/shadcn_components/ui/select.tsx @@ -2,14 +2,11 @@ import * as React from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; -import { Check, ChevronDown } from "lucide-react"; - +import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { cn } from "@/lib/utils"; const Select = SelectPrimitive.Root; - const SelectGroup = SelectPrimitive.Group; - const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< @@ -18,11 +15,11 @@ const SelectTrigger = React.forwardRef< >(({ className, children, ...props }, ref) => ( {children} @@ -32,22 +29,60 @@ const SelectTrigger = React.forwardRef< )); SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + position?: "popper" | "item-aligned"; + } >(({ className, children, position = "popper", ...props }, ref) => ( + {children} + )); @@ -68,8 +104,8 @@ const SelectLabel = React.forwardRef< >(({ className, ...props }, ref) => ( )); SelectLabel.displayName = SelectPrimitive.Label.displayName; @@ -80,18 +116,17 @@ const SelectItem = React.forwardRef< >(({ className, children, ...props }, ref) => ( - {children} )); @@ -103,8 +138,8 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )); SelectSeparator.displayName = SelectPrimitive.Separator.displayName; @@ -118,4 +153,6 @@ export { SelectLabel, SelectItem, SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, }; diff --git a/tailwind.config.js b/tailwind.config.js index b35a54e..30f1628 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -12,227 +12,230 @@ module.exports = { "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}", ], theme: { - transparent: 'transparent', - current: 'currentColor', - container: { - center: true, - padding: '2rem', - screens: { - '2xl': '1400px' - } - }, - extend: { - backgroundImage: { - 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', - 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))' - }, - colors: { - tremor: { - brand: { - faint: 'colors.blue[50]', - muted: 'colors.blue[200]', - subtle: 'colors.blue[400]', - DEFAULT: 'colors.blue[500]', - emphasis: 'colors.blue[700]', - inverted: 'colors.white' - }, - background: { - muted: 'colors.gray[50]', - subtle: 'colors.gray[100]', - DEFAULT: 'colors.white', - emphasis: 'colors.gray[700]' - }, - border: { - DEFAULT: 'colors.gray[200]' - }, - ring: { - DEFAULT: 'colors.gray[200]' - }, - content: { - subtle: 'colors.gray[400]', - DEFAULT: 'colors.gray[500]', - emphasis: 'colors.gray[700]', - strong: 'colors.gray[900]', - inverted: 'colors.white' - } - }, - 'dark-tremor': { - brand: { - faint: '#0B1229', - muted: 'colors.blue[950]', - subtle: 'colors.blue[800]', - DEFAULT: 'colors.blue[500]', - emphasis: 'colors.blue[400]', - inverted: 'colors.blue[950]' - }, - background: { - muted: '#131A2B', - subtle: 'colors.gray[800]', - DEFAULT: 'colors.gray[900]', - emphasis: 'colors.gray[300]' - }, - border: { - DEFAULT: 'colors.gray[800]' - }, - ring: { - DEFAULT: 'colors.gray[800]' - }, - content: { - subtle: 'colors.gray[600]', - DEFAULT: 'colors.gray[500]', - emphasis: 'colors.gray[200]', - strong: 'colors.gray[50]', - inverted: 'colors.gray[950]' - } - }, - epc_a: '#117d58', - epc_b: '#2da55c', - epc_c: '#8dbd40', - epc_d: '#f7cd14', - epc_e: '#f3a96a', - epc_f: '#ef8026', - epc_g: '#e41e3b', - brandblue: '#14163d', - hoverblue: '#3e4073', - brandtan: '#d3b488', - hovertan: '#947750', - brandgold: '#f1bb06', - hovergold: '#c79d12', - brandbrown: '#c4a47c', - brandmidblue: '#3943b7', - brandlightblue: '#00a9f4', - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - } - }, - textColor: { - brandblue: '#14163d', - hoverblue: '#3e4073', - brandtan: '#d3b488', - hovertan: '#947750', - brandbrown: '#c4a47c', - brandmidblue: '#3943b7', - brandlightblue: '#00a9f4' - }, - borderRadius: { - 'tremor-small': '0.375rem', - 'tremor-default': '0.5rem', - 'tremor-full': '9999px' - }, - fontFamily: { - sans: [ - 'var(--font-sans)', - ...fontFamily.sans - ] - }, - keyframes: { - 'accordion-down': { - from: { - height: 0 - }, - to: { - height: 'var(--radix-accordion-content-height)' - } - }, - 'accordion-up': { - from: { - height: 'var(--radix-accordion-content-height)' - }, - to: { - height: 0 - } - }, - 'accordion-down': { - from: { - height: '0' - }, - to: { - height: 'var(--radix-accordion-content-height)' - } - }, - 'accordion-up': { - from: { - height: 'var(--radix-accordion-content-height)' - }, - to: { - height: '0' - } - } - }, - animation: { - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out', - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out' - }, - maxWidth: { - '8xl': '90rem' - }, - boxShadow: { - 'tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', - 'tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', - 'tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', - 'dark-tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', - 'dark-tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', - 'dark-tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)' - }, - fontSize: { - 'tremor-label': [ - '0.75rem', - { - lineHeight: '1rem' - } - ], - 'tremor-default': [ - '0.875rem', - { - lineHeight: '1.25rem' - } - ], - 'tremor-title': [ - '1.125rem', - { - lineHeight: '1.75rem' - } - ], - 'tremor-metric': [ - '1.875rem', - { - lineHeight: '2.25rem' - } - ] - } - } + transparent: "transparent", + current: "currentColor", + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + colors: { + tremor: { + brand: { + faint: "colors.blue[50]", + muted: "colors.blue[200]", + subtle: "colors.blue[400]", + DEFAULT: "colors.blue[500]", + emphasis: "colors.blue[700]", + inverted: "colors.white", + }, + background: { + muted: "colors.gray[50]", + subtle: "colors.gray[100]", + DEFAULT: "colors.white", + emphasis: "colors.gray[700]", + }, + border: { + DEFAULT: "colors.gray[200]", + }, + ring: { + DEFAULT: "colors.gray[200]", + }, + content: { + subtle: "colors.gray[400]", + DEFAULT: "colors.gray[500]", + emphasis: "colors.gray[700]", + strong: "colors.gray[900]", + inverted: "colors.white", + }, + }, + "dark-tremor": { + brand: { + faint: "#0B1229", + muted: "colors.blue[950]", + subtle: "colors.blue[800]", + DEFAULT: "colors.blue[500]", + emphasis: "colors.blue[400]", + inverted: "colors.blue[950]", + }, + background: { + muted: "#131A2B", + subtle: "colors.gray[800]", + DEFAULT: "colors.gray[900]", + emphasis: "colors.gray[300]", + }, + border: { + DEFAULT: "colors.gray[800]", + }, + ring: { + DEFAULT: "colors.gray[800]", + }, + content: { + subtle: "colors.gray[600]", + DEFAULT: "colors.gray[500]", + emphasis: "colors.gray[200]", + strong: "colors.gray[50]", + inverted: "colors.gray[950]", + }, + }, + epc_a: "#117d58", + epc_b: "#2da55c", + epc_c: "#8dbd40", + epc_d: "#f7cd14", + epc_e: "#f3a96a", + epc_f: "#ef8026", + epc_g: "#e41e3b", + brandblue: "#14163d", + hoverblue: "#3e4073", + midblue: "#2d348f", + brandtan: "#d3b488", + hovertan: "#947750", + brandgold: "#f1bb06", + hovergold: "#c79d12", + brandbrown: "#c4a47c", + brandmidblue: "#3943b7", + brandlightblue: "#eff6fc", + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + textColor: { + brandblue: "#14163d", + hoverblue: "#3e4073", + brandtan: "#d3b488", + hovertan: "#947750", + brandbrown: "#c4a47c", + brandmidblue: "#3943b7", + brandlightblue: "#00a9f4", + }, + borderRadius: { + "tremor-small": "0.375rem", + "tremor-default": "0.5rem", + "tremor-full": "9999px", + }, + fontFamily: { + sans: ["var(--font-sans)", ...fontFamily.sans], + }, + keyframes: { + "accordion-down": { + from: { + height: 0, + }, + to: { + height: "var(--radix-accordion-content-height)", + }, + }, + "accordion-up": { + from: { + height: "var(--radix-accordion-content-height)", + }, + to: { + height: 0, + }, + }, + "accordion-down": { + from: { + height: "0", + }, + to: { + height: "var(--radix-accordion-content-height)", + }, + }, + "accordion-up": { + from: { + height: "var(--radix-accordion-content-height)", + }, + to: { + height: "0", + }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + maxWidth: { + "8xl": "90rem", + }, + boxShadow: { + "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "tremor-card": + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "tremor-dropdown": + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "dark-tremor-card": + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "dark-tremor-dropdown": + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + }, + fontSize: { + "tremor-label": [ + "0.75rem", + { + lineHeight: "1rem", + }, + ], + "tremor-default": [ + "0.875rem", + { + lineHeight: "1.25rem", + }, + ], + "tremor-title": [ + "1.125rem", + { + lineHeight: "1.75rem", + }, + ], + "tremor-metric": [ + "1.875rem", + { + lineHeight: "2.25rem", + }, + ], + }, + }, }, variants: { extend: { From 40147913069c4af469f4f3fea9c8937a24cb3c72 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 15 Oct 2025 14:52:39 +0000 Subject: [PATCH 5/8] onboarding logic working --- src/app/api/auth/[...nextauth]/route.ts | 1 + src/app/layout.tsx | 2 +- src/app/onboarding/page.tsx | 296 +++++++++++++++++------- 3 files changed, 208 insertions(+), 91 deletions(-) diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index ce774a0..e12c3ab 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -3,6 +3,7 @@ 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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 09f028e..610ab05 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -27,7 +27,7 @@ const getSession = cache(async () => { export function Footer() { return ( -