diff --git a/prisma/migrations/20260211090850_add_onboarding_fields/migration.sql b/prisma/migrations/20260211090850_add_onboarding_fields/migration.sql new file mode 100644 index 00000000..00e7c951 --- /dev/null +++ b/prisma/migrations/20260211090850_add_onboarding_fields/migration.sql @@ -0,0 +1,33 @@ +-- CreateEnum +CREATE TYPE "LearningFrequency" AS ENUM ('QUICK', 'REGULAR', 'DEEPDIVE'); + +-- CreateTable +CREATE TABLE "user_profiles" ( + "user_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "learning_frequency" "LearningFrequency", + "is_subscribed" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "user_profiles_pkey" PRIMARY KEY ("user_id") +); + +-- CreateTable +CREATE TABLE "user_interests" ( + "user_id" UUID NOT NULL, + "collection_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + + CONSTRAINT "user_interests_pkey" PRIMARY KEY ("user_id","collection_id") +); + +-- AddForeignKey +ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_interests" ADD CONSTRAINT "user_interests_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user_profiles"("user_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_interests" ADD CONSTRAINT "user_interests_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 55e693a3..2d6ad294 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ model User { avatarURL String @map("avatar_url") // Relations. + userProfile UserProfile? learningJourneys LearningJourney[] threads Thread[] sentiments LearningUnitSentiments[] @@ -26,6 +27,20 @@ model User { @@map("users") } +model UserProfile { + userId String @id @map("user_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + learningFrequency LearningFrequency? @map("learning_frequency") + isSubscribed Boolean @default(false) @map("is_subscribed") + + // Relations. + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + interests UserInterest[] + + @@map("user_profiles") +} + model Collection { id String @id @default(dbgenerated("uuid_generate_v7()")) @map("id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) @@ -37,7 +52,8 @@ model Collection { type CollectionType @map("type") // Relations. - learningUnit LearningUnit[] + learningUnit LearningUnit[] + userInterests UserInterest[] @@map("collections") } @@ -247,3 +263,23 @@ model QuestionAnswer { @@map("question_answers") } + +enum LearningFrequency { + QUICK + REGULAR + DEEPDIVE +} + +model UserInterest { + userId String @map("user_id") @db.Uuid + collectionId String @map("collection_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + // Relations. + userProfile UserProfile @relation(fields: [userId], references: [userId], onDelete: Cascade) + collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade) + + @@id([userId, collectionId]) + @@map("user_interests") +} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9e0cadbc..5cce135b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -15,18 +15,18 @@ const requestLoggingHandle: Handle = async ({ event, resolve }) => { event.setHeaders({ 'X-Request-Id': requestId }); event.locals.logger = logger.child({ requestId }); - return await resolve(event); + return resolve(event); }; /** * A handle that enforces authentication on protected routes. * - Requests to `/api/*` are always allowed through - * - Authenticated users visiting `/login` are redirected back to `/` + * - Authenticated users visiting `/login` are redirected back to HOME_PATH * - Unauthenticated users are redirected to `/login` */ const routeProtectionHandle: Handle = async ({ event, resolve }) => { if (event.url.pathname.startsWith('/api/')) { - return await resolve(event); + return resolve(event); } if (event.locals.session.isAuthenticated) { @@ -34,7 +34,7 @@ const routeProtectionHandle: Handle = async ({ event, resolve }) => { return redirect(302, HOME_PATH); } - return await resolve(event); + return resolve(event); } if ( @@ -44,7 +44,7 @@ const routeProtectionHandle: Handle = async ({ event, resolve }) => { event.url.pathname === '/terms' || event.url.pathname === '/privacy' ) { - return await resolve(event); + return resolve(event); } return redirect(303, `/login?return_to=${encodeURIComponent(event.url.pathname)}`); diff --git a/src/lib/assets/cycling-man.png b/src/lib/assets/cycling-man.png new file mode 100644 index 00000000..b27940cc Binary files /dev/null and b/src/lib/assets/cycling-man.png differ diff --git a/src/lib/assets/onboarding-bites.png b/src/lib/assets/onboarding-bites.png new file mode 100644 index 00000000..ad24225b Binary files /dev/null and b/src/lib/assets/onboarding-bites.png differ diff --git a/src/lib/components/OnboardingView/OnboardingView.svelte b/src/lib/components/OnboardingView/OnboardingView.svelte new file mode 100644 index 00000000..4e942cb4 --- /dev/null +++ b/src/lib/components/OnboardingView/OnboardingView.svelte @@ -0,0 +1,551 @@ + + + + + {#if isStartPage} +
+
+ +
+
+
+ + {`Hi${page.data.username ? ` ${page.data.username}` : ''}!`} + + Welcome to Glow! +
+ +
+ You are already ahead in personal + growth as MOE teacher / staff. + Let's get started +
+
+
+ +
+ +
+ +
+
+ +
+
+
+ {:else if isTopicSelectionPage} +
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+ What interests you? +
+ +
+ Select at least 3 topics you'd like to learn about +
+
+
+ +
+
+ {#each Object.entries(collectionsList) as [key, { title, description, type }] (key)} + + {/each} +
+
+
+ +
+ +
+
+
+ {:else if isFrequencySelectionPage} +
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+ How long can you invest in learning per day? +
+ +
+ Before and/or after the long and tiring + teaching duties ended! +
+
+
+ +
+ + + + + +
+ + {#if selectedFrequency} +
+
+ {#if selectedFrequency === 'QUICK'} + + 5 mins per day sounds small, but that's actually 30 bites per month! + + {/if} + {#if selectedFrequency === 'REGULAR'} + + 10 mins per day sounds small, but that's actually 60 bites per month! + + {/if} + {#if selectedFrequency === 'DEEPDIVE'} + + 15 mins per day sounds small, but that's actually 90 bites per month! + + {/if} +
+
+ {/if} +
+ +
+ +
+
+
+ {:else if isSubscribePage} +
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+ One last thing! +
+ +
+ Want fresh learning bites delivered to your inbox? + Get a monthly serving of educator stories, trending topics, and classroom-ready + tips. + +
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ {/if} +
diff --git a/src/lib/components/OnboardingView/index.ts b/src/lib/components/OnboardingView/index.ts new file mode 100644 index 00000000..ee015794 --- /dev/null +++ b/src/lib/components/OnboardingView/index.ts @@ -0,0 +1 @@ +export { type Props as OnboardingProps, default as OnboardingView } from './OnboardingView.svelte'; diff --git a/src/routes/(protected)/+layout.server.ts b/src/routes/(protected)/+layout.server.ts index 16ac0712..7a07a0af 100644 --- a/src/routes/(protected)/+layout.server.ts +++ b/src/routes/(protected)/+layout.server.ts @@ -1,18 +1,44 @@ -import { redirect } from '@sveltejs/kit'; +import { error, redirect } from '@sveltejs/kit'; + +import { db, type UserFindUniqueArgs, type UserGetPayload } from '$lib/server/db'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async (event) => { const { user } = event.locals.session; - if (!user) { - const logger = event.locals.logger.child({ handler: 'layout_protected' }); + const logger = event.locals.logger.child({ handler: 'layout_protected' }); + if (!user) { logger.warn('User not authenticated'); return redirect(303, '/login'); } + const userArgs = { + select: { + userProfile: true, + }, + where: { id: user.id }, + } satisfies UserFindUniqueArgs; + + let userData: UserGetPayload | null; + + try { + userData = await db.user.findUnique(userArgs); + } catch (err) { + logger.error({ err }, 'Failed to retrieve user record'); + throw error(500); + } + + if (!userData) { + logger.error('User not found'); + throw error(404); + } + + const onboarded = userData?.userProfile; + return { username: user.name, csrfToken: event.locals.session.csrfToken(), + onboarded, }; }; diff --git a/src/routes/(protected)/+layout.svelte b/src/routes/(protected)/+layout.svelte index 140810fa..b5c78ff9 100644 --- a/src/routes/(protected)/+layout.svelte +++ b/src/routes/(protected)/+layout.svelte @@ -9,6 +9,7 @@ import { Modal } from '$lib/components/Modal/index.js'; import { NowPlayingBar } from '$lib/components/NowPlayingBar/index.js'; import { NowPlayingView } from '$lib/components/NowPlayingView/index.js'; + import { OnboardingView } from '$lib/components/OnboardingView/index.js'; import { noop, track20PercentPodcastPlay, @@ -28,6 +29,7 @@ let isChatViewOpen = $state(false); let isTrackingSession = $state(false); let isPodcastCompletedModalOpen = $state(false); + let isOnboardingViewOpen = $derived(!page.data.onboarded); const isQuizPage = $derived(page.url.pathname.includes('/quiz')); @@ -72,6 +74,10 @@ isChatViewOpen = false; }; + const handleOnboardingViewClose = () => { + isOnboardingViewOpen = false; + }; + const handlePodcastCompletedModalClose = () => { isPodcastCompletedModalOpen = false; }; @@ -311,3 +317,5 @@ {/if} + + diff --git a/src/routes/api/onboarding/+server.ts b/src/routes/api/onboarding/+server.ts new file mode 100644 index 00000000..4904d135 --- /dev/null +++ b/src/routes/api/onboarding/+server.ts @@ -0,0 +1,81 @@ +import { json } from '@sveltejs/kit'; + +import { db, type UserProfileCreateArgs } from '$lib/server/db'; + +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ + handler: 'api_update_onboarding', + }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + return json(null, { status: 401 }); + } + + if (event.request.headers.get('content-type')?.split(';')[0] !== 'application/json') { + return json(null, { status: 415 }); + } + + let params; + try { + params = await event.request.json(); + if ( + !params || + typeof params !== 'object' || + !('topics' in params) || + !Array.isArray(params['topics']) || + params['topics'].length < 3 || + !('frequency' in params) || + typeof params['frequency'] !== 'string' || + !('csrfToken' in params) || + typeof params['csrfToken'] !== 'string' + ) { + return json(null, { status: 422 }); + } + } catch (err) { + logger.error({ err, userId: user.id }, 'Failed to parse request body'); + return json(null, { status: 400 }); + } + + const { topics, frequency } = params; + + try { + const collections = await db.collection.findMany({ + where: { + type: { + in: topics, + }, + }, + select: { + id: true, + }, + }); + + if (collections.length === 0) { + logger.warn({ topics }, 'No valid collections found'); + return json(null, { status: 400 }); + } + + const userProfileArgs = { + data: { + userId: user.id, + learningFrequency: frequency, + interests: { + create: collections.map((collection) => ({ + collectionId: collection.id, + })), + }, + }, + } satisfies UserProfileCreateArgs; + + await db.userProfile.create(userProfileArgs); + } catch (err) { + logger.error({ err }, 'Failed to complete onboarding'); + return json(null, { status: 500 }); + } + + return json(null, { status: 200 }); +}; diff --git a/src/routes/api/subscribe/+server.ts b/src/routes/api/subscribe/+server.ts new file mode 100644 index 00000000..023935fa --- /dev/null +++ b/src/routes/api/subscribe/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; + +import { db } from '$lib/server/db'; + +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ + handler: 'api_update_subscribe', + }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + return json(null, { status: 401 }); + } + + if (event.request.headers.get('content-type')?.split(';')[0] !== 'application/json') { + return json(null, { status: 415 }); + } + + let params; + try { + params = await event.request.json(); + if ( + !params || + typeof params !== 'object' || + !('isSubscribed' in params) || + typeof params['isSubscribed'] !== 'boolean' || + !('csrfToken' in params) || + typeof params['csrfToken'] !== 'string' + ) { + return json(null, { status: 422 }); + } + } catch (err) { + logger.error({ err, userId: user.id }, 'Failed to parse request body'); + return json(null, { status: 400 }); + } + + const { isSubscribed } = params; + + try { + await db.userProfile.update({ + data: { + isSubscribed, + }, + where: { userId: user.id }, + }); + } catch (err) { + logger.error({ err }, "Failed to update user's subscription"); + return json(null, { status: 500 }); + } + + return json(null, { status: 200 }); +};