diff --git a/src/app/admin/layout.tsx b/src/app/(protected)/admin/layout.tsx similarity index 100% rename from src/app/admin/layout.tsx rename to src/app/(protected)/admin/layout.tsx diff --git a/src/app/admin/page.tsx b/src/app/(protected)/admin/page.tsx similarity index 100% rename from src/app/admin/page.tsx rename to src/app/(protected)/admin/page.tsx diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx new file mode 100644 index 0000000..8c55644 --- /dev/null +++ b/src/app/(protected)/layout.tsx @@ -0,0 +1,18 @@ +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { auth } from '@src/server/auth'; +import { isOnboarded } from '@src/server/onboarding'; + +export default async function ProtectedLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth.api.getSession({ headers: await headers() }); + + if (session && !(await isOnboarded(session.user.id))) { + redirect('/get-started'); + } + + return <>{children}; +} diff --git a/src/app/notes/[...slug]/layout.tsx b/src/app/(protected)/notes/[...slug]/layout.tsx similarity index 100% rename from src/app/notes/[...slug]/layout.tsx rename to src/app/(protected)/notes/[...slug]/layout.tsx diff --git a/src/app/notes/[...slug]/page.tsx b/src/app/(protected)/notes/[...slug]/page.tsx similarity index 100% rename from src/app/notes/[...slug]/page.tsx rename to src/app/(protected)/notes/[...slug]/page.tsx diff --git a/src/app/notes/[id]/edit/page.tsx b/src/app/(protected)/notes/[id]/edit/page.tsx similarity index 100% rename from src/app/notes/[id]/edit/page.tsx rename to src/app/(protected)/notes/[id]/edit/page.tsx diff --git a/src/app/notes/[id]/page.tsx b/src/app/(protected)/notes/[id]/page.tsx similarity index 100% rename from src/app/notes/[id]/page.tsx rename to src/app/(protected)/notes/[id]/page.tsx diff --git a/src/app/notes/create/page.tsx b/src/app/(protected)/notes/create/page.tsx similarity index 100% rename from src/app/notes/create/page.tsx rename to src/app/(protected)/notes/create/page.tsx diff --git a/src/app/page.tsx b/src/app/(protected)/page.tsx similarity index 100% rename from src/app/page.tsx rename to src/app/(protected)/page.tsx diff --git a/src/app/profile/[username]/page.tsx b/src/app/(protected)/profile/[username]/page.tsx similarity index 100% rename from src/app/profile/[username]/page.tsx rename to src/app/(protected)/profile/[username]/page.tsx diff --git a/src/app/profile/page.tsx b/src/app/(protected)/profile/page.tsx similarity index 100% rename from src/app/profile/page.tsx rename to src/app/(protected)/profile/page.tsx diff --git a/src/app/report/page.tsx b/src/app/(protected)/report/page.tsx similarity index 100% rename from src/app/report/page.tsx rename to src/app/(protected)/report/page.tsx diff --git a/src/app/settings/page.tsx b/src/app/(protected)/settings/page.tsx similarity index 100% rename from src/app/settings/page.tsx rename to src/app/(protected)/settings/page.tsx diff --git a/src/lib/components/form/FormAutocomplete.tsx b/src/lib/components/form/FormAutocomplete.tsx index 9054dc7..c04be3a 100644 --- a/src/lib/components/form/FormAutocomplete.tsx +++ b/src/lib/components/form/FormAutocomplete.tsx @@ -6,12 +6,14 @@ type FormAutocompleteFreeSoloProps = { label: string; options: string[]; className?: string; + required?: boolean; }; export function FormAutocompleteFreeSolo({ label, options, className, + required, ...props }: FormAutocompleteFreeSoloProps) { const field = useFieldContext(); @@ -29,6 +31,7 @@ export function FormAutocompleteFreeSolo({ ; export const accountSettingsSchema = z.object({ firstName: z.string().min(1, 'First name is required'), lastName: z.string().min(1, 'Last name is required'), - major: z.string().min(1, 'College major is required'), + major: z.string().trim().min(1, 'College major is required'), minor: z.string().nullable(), studentClassification: z.enum(studentClassificationEnum.enumValues), graduationDate: z.date().nullable(), contactEmail: z .email({ error: 'Use your UT Dallas email', - pattern: - /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9\-]*\.)*utdallas\.edu$/i, + pattern: UTD_EMAIL_REGEX, }) .nullable(), }); @@ -37,18 +40,31 @@ export type AccountSettingsSchema = z.infer; export const accountOnboardingSchema = z.object({ firstName: z.string().min(1, 'Name is required'), lastName: z.string().optional(), - major: z.string().optional(), + major: z.string().trim().min(1, 'College major is required'), minor: z.string().nullable().optional(), studentClassification: z.enum(studentClassificationEnum.enumValues), graduationDate: z.date({ error: 'Graduation date is required' }).nullable(), contactEmail: z .email({ error: 'Use your UT Dallas email', - pattern: - /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9\-]*\.)*utdallas\.edu$/i, + pattern: UTD_EMAIL_REGEX, }) .min(1, 'Contact email is required') .nullable(), }); export type AccountOnboardingSchema = z.infer; + +export function isProfileComplete( + userMetadata: + | Pick + | null + | undefined, +): boolean { + if (!userMetadata) return false; + return ( + userMetadata.major.trim().length > 0 && + !!userMetadata.contactEmail && + UTD_EMAIL_REGEX.test(userMetadata.contactEmail) + ); +} diff --git a/src/server/api/routers/file.ts b/src/server/api/routers/file.ts index e2da5ee..62d434c 100644 --- a/src/server/api/routers/file.ts +++ b/src/server/api/routers/file.ts @@ -6,7 +6,12 @@ import { createFileSchema, editFileSchema } from '@src/lib/schemas/note'; import { file as files } from '@src/server/db/schema/file'; import { section as sections } from '@src/server/db/schema/section'; import { callStorageAPI } from '@src/server/storage'; -import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; +import { + createTRPCRouter, + onboardedProcedure, + protectedProcedure, + publicProcedure, +} from '../trpc'; const byIdSchema = z.object({ id: z.string().default(''), @@ -91,7 +96,7 @@ export const fileRouter = createTRPCRouter({ return files; }), - create: protectedProcedure + create: onboardedProcedure .input(createFileSchema) .mutation(async ({ input, ctx }) => { const userId = ctx.session.user.id; @@ -158,7 +163,7 @@ export const fileRouter = createTRPCRouter({ }); return newFile.id; }), - update: protectedProcedure + update: onboardedProcedure .input(editFileSchema) .mutation(async ({ input, ctx }) => { const { id, ...data } = input; diff --git a/src/server/api/routers/report.ts b/src/server/api/routers/report.ts index 170521f..1be2cf7 100644 --- a/src/server/api/routers/report.ts +++ b/src/server/api/routers/report.ts @@ -2,10 +2,10 @@ import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { createReportSchema } from '@src/lib/schemas/moderation'; import { report as reports } from '@src/server/db/schema/reports'; -import { createTRPCRouter, protectedProcedure } from '../trpc'; +import { createTRPCRouter, onboardedProcedure } from '../trpc'; export const reportRouter = createTRPCRouter({ - create: protectedProcedure + create: onboardedProcedure .input(createReportSchema) .mutation(async ({ input, ctx }) => { const userId = ctx.session.user.id; diff --git a/src/server/api/routers/savedNote.ts b/src/server/api/routers/savedNote.ts index bdaf893..382b588 100644 --- a/src/server/api/routers/savedNote.ts +++ b/src/server/api/routers/savedNote.ts @@ -1,14 +1,19 @@ import { and, avg, count, eq, isNotNull } from 'drizzle-orm'; import { z } from 'zod'; import { userMetadataToNotes } from '@src/server/db/schema/savedNote'; -import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; +import { + createTRPCRouter, + onboardedProcedure, + protectedProcedure, + publicProcedure, +} from '../trpc'; const byFileIdSchema = z.object({ fileId: z.string(), }); export const savedNoteRouter = createTRPCRouter({ - toggle: protectedProcedure + toggle: onboardedProcedure .input(byFileIdSchema) .mutation(async ({ input, ctx }) => { const userId = ctx.session.user.id; @@ -117,7 +122,7 @@ export const savedNoteRouter = createTRPCRouter({ return rows.map((row) => row.file); }), - rate: protectedProcedure + rate: onboardedProcedure .input( z.object({ fileId: z.string(), diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index d4012d5..d311d1c 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -15,6 +15,7 @@ import superjson from 'superjson'; import { ZodError } from 'zod'; import { auth } from '@src/server/auth'; import { db } from '@src/server/db'; +import { isOnboarded } from '@src/server/onboarding'; /** * 1. CONTEXT @@ -116,6 +117,18 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { */ export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); +export const onboardedProcedure = protectedProcedure.use( + async ({ ctx, next }) => { + if (!(await isOnboarded(ctx.session.user.id))) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Finish onboarding before doing this.', + }); + } + return next(); + }, +); + /** * Admin procedures * Make sure the user invoking the procedure is an admin diff --git a/src/server/onboarding.ts b/src/server/onboarding.ts new file mode 100644 index 0000000..7e27a5d --- /dev/null +++ b/src/server/onboarding.ts @@ -0,0 +1,11 @@ +import { eq } from 'drizzle-orm'; +import { isProfileComplete } from '@src/lib/schemas/account'; +import { db } from '@src/server/db'; +import { userMetadata } from '@src/server/db/schema/user'; + +export async function isOnboarded(userId: string): Promise { + const metadata = await db.query.userMetadata.findFirst({ + where: eq(userMetadata.id, userId), + }); + return isProfileComplete(metadata); +} diff --git a/src/systems/account/components/getting-started/OnboardingFormStep.tsx b/src/systems/account/components/getting-started/OnboardingFormStep.tsx index 7bc519c..f8f206f 100644 --- a/src/systems/account/components/getting-started/OnboardingFormStep.tsx +++ b/src/systems/account/components/getting-started/OnboardingFormStep.tsx @@ -63,6 +63,7 @@ const OnboardingFormStep = withForm({ label="Major" options={majors} className="grow" + required /> )}