onboarding working

This commit is contained in:
Khalim Conn-Kowlessar 2025-10-15 19:07:10 +00:00
parent 4014791306
commit a264686552
14 changed files with 4097 additions and 42 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

View file

@ -147,14 +147,24 @@ export const AuthOptions: NextAuthOptions = {
/**
* Persist dbId in the JWT so its available in sessions
*/
async jwt({ token, user }) {
if (user?.dbId) {
token.dbId = user.dbId;
async jwt({ token, user: userFromLogin }) {
// Initial sign-in: attach DB fields
if (userFromLogin) {
const existing = await db.query.user.findFirst({
where: eq(users.email, userFromLogin.email!),
});
if (existing) {
token.onboarded = existing.onboarded;
}
} else if (token.email) {
// On subsequent calls, keep token synced from DB
const existing = await db.query.user.findFirst({
where: eq(users.email, token.email),
});
if (existing) {
token.onboarded = existing.onboarded;
}
}
// Fetch onboarding status from user
if (user?.onboarded !== undefined) token.onboarded = user.onboarded;
return token;
},

View file

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/app/db/db";
import { user } from "@/app/db/schema/users";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
const OnboardedSchema = z.object({
onboarded: z.boolean(),
});
export async function PATCH(req: NextRequest) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return new NextResponse(JSON.stringify({ msg: "Unauthenticated" }), {
status: 401,
});
}
const body = await req.json();
const { onboarded } = OnboardedSchema.parse(body);
const existingUser = await db.query.user.findFirst({
where: eq(user.email, session.user.email),
});
if (!existingUser) {
return new NextResponse(JSON.stringify({ msg: "User not found" }), {
status: 404,
});
}
await db
.update(user)
.set({ onboarded, updatedAt: new Date() })
.where(eq(user.id, existingUser.id));
return new NextResponse(
JSON.stringify({ msg: "User marked as onboarded" }),
{ status: 200 }
);
} catch (err) {
console.error("Error updating onboarded flag:", err);
if (err instanceof z.ZodError) {
return new NextResponse(
JSON.stringify({ msg: "Invalid input", errors: err.flatten() }),
{
status: 400,
}
);
}
return new NextResponse(JSON.stringify({ msg: "Server error" }), {
status: 500,
});
}
}

View file

@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/app/db/db";
import { userProfiles, user } from "@/app/db/schema/users";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
const UserProfileSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
userType: z.enum([
"private_landlord",
"private_tenant",
"social_landlord",
"social_tenant",
"homeowner",
"other",
]),
propertyCount: z
.enum([
"1",
"25",
"620",
"21+",
"150",
"51100",
"101300",
"3011000",
"1000+",
])
.optional(),
goals: z
.array(
z.enum([
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
])
)
.min(1),
referralSource: z.enum([
"search",
"social_media",
"NRLA",
"partner",
"word_of_mouth",
"other",
]),
nrlaMembershipId: z.string().optional(),
marketingOptIn: z.boolean().optional(),
acceptedPrivacy: z.boolean().refine((v) => v === true, {
message: "You must accept our privacy policy to continue",
}),
});
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return new NextResponse(JSON.stringify({ msg: "Unauthenticated" }), {
status: 401,
});
}
const body = await req.json();
const parsed = UserProfileSchema.parse(body);
// 1⃣ Get user from DB
const existingUser = await db.query.user.findFirst({
where: eq(user.email, session.user.email),
});
if (!existingUser) {
return new NextResponse(JSON.stringify({ msg: "User not found" }), {
status: 404,
});
}
// 2⃣ Check if profile already exists
const existingProfile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, existingUser.id),
});
// Timestamps for policy and marketing
const now = new Date();
const acceptedPrivacyAt = parsed.acceptedPrivacy ? now : null;
const marketingOptInAt = parsed.marketingOptIn ? now : null;
if (existingProfile) {
await db
.update(userProfiles)
.set({
firstName: parsed.firstName,
lastName: parsed.lastName,
userType: parsed.userType,
propertyCount: parsed.propertyCount,
goals: parsed.goals,
referralSource: parsed.referralSource,
nrlaMembershipId: parsed.nrlaMembershipId,
marketingOptIn: parsed.marketingOptIn ?? false,
acceptedPrivacy: parsed.acceptedPrivacy,
acceptedPrivacyAt,
marketingOptInAt,
updatedAt: now,
})
.where(eq(userProfiles.userId, existingUser.id));
} else {
await db.insert(userProfiles).values({
userId: existingUser.id,
firstName: parsed.firstName,
lastName: parsed.lastName,
userType: parsed.userType,
propertyCount: parsed.propertyCount,
goals: parsed.goals,
referralSource: parsed.referralSource,
nrlaMembershipId: parsed.nrlaMembershipId,
marketingOptIn: parsed.marketingOptIn ?? false,
acceptedPrivacy: parsed.acceptedPrivacy,
acceptedPrivacyAt,
marketingOptInAt,
});
}
return new NextResponse(JSON.stringify({ msg: "Profile saved" }), {
status: 200,
});
} catch (err) {
console.error("Error saving user profile:", err);
if (err instanceof z.ZodError) {
return new NextResponse(
JSON.stringify({ msg: "Invalid input", errors: err.flatten() }),
{
status: 400,
}
);
}
return new NextResponse(JSON.stringify({ msg: "Server error" }), {
status: 500,
});
}
}

View file

@ -9,6 +9,7 @@ import * as solarSchema from "@/app/db/schema/solar";
import * as EnergyAssessmentsSchema from "@/app/db/schema/energy_assessments";
import * as FundingSchema from "@/app/db/schema/funding";
import * as Relations from "@/app/db/schema/relations";
import * as Users from "@/app/db/schema/users";
export const pool = new Pool({
host: process.env.DB_HOST,
@ -29,6 +30,7 @@ const schema = {
...Relations,
...EnergyAssessmentsSchema,
...FundingSchema,
...Users,
};
export const db = drizzle(pool, {

View file

@ -0,0 +1,22 @@
CREATE TYPE "public"."user_profiles_property_count" AS ENUM('1', '25', '620', '21+', '150', '51100', '101300', '3011000', '1000+');--> statement-breakpoint
CREATE TYPE "public"."user_profiles_referral_source" AS ENUM('search', 'social_media', 'NRLA', 'partner', 'word_of_mouth', 'other');--> statement-breakpoint
CREATE TYPE "public"."user_profiles_user_type" AS ENUM('private_landlord', 'private_tenant', 'social_landlord', 'social_tenant', 'homeowner', 'other');--> statement-breakpoint
CREATE TABLE "user_profiles" (
"id" bigserial PRIMARY KEY NOT NULL,
"user_id" bigint NOT NULL,
"user_type" "user_profiles_user_type" NOT NULL,
"property_count" "user_profiles_property_count",
"goals" json,
"referral_source" "user_profiles_referral_source",
"nrla_membership_id" varchar(255),
"accepted_privacy" boolean DEFAULT false NOT NULL,
"accepted_privacy_at" timestamp (6) with time zone,
"marketing_opt_in" boolean DEFAULT false,
"marketing_opt_in_at" timestamp (6) with time zone,
"first_name" text,
"last_name" text,
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -827,6 +827,13 @@
"when": 1760191704756,
"tag": "0117_colossal_bastion",
"breakpoints": true
},
{
"idx": 118,
"version": "7",
"when": 1760552402393,
"tag": "0118_lazy_gabe_jones",
"breakpoints": true
}
]
}

View file

@ -119,10 +119,15 @@ export const Goal: [string, ...string[]] = [
"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);
export const userTypeEnum = pgEnum("user_profiles_user_type", UserType);
export const propertyCountEnum = pgEnum(
"user_profiles_property_count",
PropertyCount
);
export const referralSourceEnum = pgEnum(
"user_profiles_referral_source",
ReferralSource
);
// ----------------------------
// MAIN TABLE

View file

@ -0,0 +1,7 @@
// Contains the email template for user sign in via magic links. A user will be asked to
// click a verification email to sign in to the app, should they choose to sign in with magic
// links
export async function MagicLinksEmail() {
return null;
}

View file

@ -0,0 +1,45 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
interface OnboardData {
firstName: string;
lastName: string;
userType: string;
propertyCount?: string;
goals: string[];
referralSource: string;
nrlaMembershipId?: string;
marketingOptIn?: boolean;
}
export function useOnboardUser() {
const router = useRouter();
const { update } = useSession();
return useMutation({
mutationFn: async (data: OnboardData) => {
// 1) Create user profile
const res1 = await fetch("/api/user/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res1.ok) throw new Error("Failed to create user profile");
// 2) Mark user as onboarded
const res2 = await fetch("/api/user/onboarded", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ onboarded: true }),
});
if (!res2.ok) throw new Error("Failed to update onboarding status");
},
onSuccess: async () => {
// 3) Redirect once both requests succeed
// Refresh the token with the next-auth session update
await update();
router.push("/home");
},
});
}

View file

@ -15,6 +15,7 @@ import {
SelectItem,
} from "@/app/shadcn_components/ui/select";
import { Fragment } from "react";
import { useOnboardUser } from "./OnboardUserHook";
const referralLabels: Record<string, string> = {
search: "Search engine (e.g. Google)",
@ -122,15 +123,10 @@ export default function OnboardingPage() {
const userType = watch("userType");
const referralSource = watch("referralSource");
const onboardMutation = useOnboardUser();
async function onSubmit(data: OnboardingData) {
console.log("Onboarding data submitted:");
await fetch("/api/user/onboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
router.push("/");
onboardMutation.mutate(data);
}
async function nextStep() {
@ -302,8 +298,7 @@ export default function OnboardingPage() {
Step 1 of 3
</h1>
<p className="text-sm font-medium text-gray-700">
By telling us a bit about yourself, we'll know how to best
support your journey.
{`By telling us a bit about yourself, we'll know how to best support your journey.`}
</p>
<Controller
control={control}
@ -408,13 +403,15 @@ export default function OnboardingPage() {
<span className="text-red-500">*</span>
</p>
<div className="grid grid-cols-2 gap-2">
{[
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
].map((g) => (
{(
[
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
] as OnboardingData["goals"]
).map((g) => (
<label
key={g}
className="flex items-center space-x-2 cursor-pointer"
@ -422,7 +419,8 @@ export default function OnboardingPage() {
<Checkbox
checked={watch("goals")?.includes(g)}
onCheckedChange={(checked) => {
const goals = watch("goals") ?? [];
const goals = (watch("goals") ??
[]) as OnboardingData["goals"];
setValue(
"goals",
checked
@ -451,9 +449,8 @@ export default function OnboardingPage() {
value={field.value}
onValueChange={(value) => {
field.onChange(value);
trigger("referralSource"); // update validation instantly
trigger("referralSource"); // update validation instantly
}}
placeholder="How did you hear about us?"
>
<SelectTrigger>
{field.value
@ -596,11 +593,17 @@ export default function OnboardingPage() {
) : (
<Button
type="submit"
className="bg-brandbrown hover:bg-hoverblue"
className="bg-brandbrown hover:bg-hoverblue flex items-center justify-center"
disabled={onboardMutation.isPending}
>
Finish
{onboardMutation.isPending ? "Finishing..." : "Finish"}
</Button>
)}
{onboardMutation.isError && (
<p className="text-sm text-red-500 mt-2">
{(onboardMutation.error as Error).message}
</p>
)}
</div>
</form>
</div>

View file

@ -1,35 +1,34 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useEffect, use } from "react";
import { useState, useEffect, use, useCallback } from "react";
export default function LoadingPage(props: { params: Promise<{ slug: string }> }) {
export default function LoadingPage(props: {
params: Promise<{ slug: string }>;
}) {
const params = use(props.params);
const portfolioId = params.slug;
const router = useRouter();
const [countdown, setCountdown] = useState(10); // Initialize countdown state to 10 seconds
const handleBackToPortfolio = () => {
const handleBackToPortfolio = useCallback(() => {
if (portfolioId) {
router.push(`/portfolio/${portfolioId}`);
} else {
router.push(`/home`);
}
};
}, [portfolioId, router]);
useEffect(() => {
// If countdown reaches zero, redirect the user
if (countdown === 0) {
handleBackToPortfolio();
return;
}
// Set up interval to decrease countdown by 1 every second
const timer = setInterval(() => {
setCountdown((prevCountdown) => prevCountdown - 1);
setCountdown((prev) => prev - 1);
}, 1000);
// Clean up the interval when the component unmounts
return () => clearInterval(timer);
}, [countdown, handleBackToPortfolio]);

View file

@ -14,10 +14,10 @@ export async function middleware(req: NextRequest) {
const userEmail = token.email || "";
// Internal users (bypass onboarding)
// const isInternal = userEmail.endsWith("@domna.homes");
const isInternal = userEmail.endsWith("@domna.homes");
// Not onboarded and not internal
if (token.onboarded === false && pathname !== "/onboarding") {
if (token.onboarded === false && pathname !== "/onboarding" && !isInternal) {
return NextResponse.redirect(new URL("/onboarding", req.url));
}
@ -26,6 +26,11 @@ export async function middleware(req: NextRequest) {
return NextResponse.redirect(new URL("/home", req.url));
}
// If internal, allow access to everything
if (isInternal) {
return NextResponse.next();
}
// Everything else allowed
return NextResponse.next();
}