feat: auto enroll into org as student and oauth improvements

This commit is contained in:
Rotimi Best
2026-05-20 08:32:56 +01:00
parent d21d89f250
commit 71fbb2101c
18 changed files with 482 additions and 21 deletions
+19 -1
View File
@@ -86,7 +86,25 @@ export const app = new Hono()
})
)
.use('/api/auth/sign-up/*', signupGuard)
.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
.on(['POST', 'GET'], '/api/auth/*', (c) => {
// Behind the Cloudflare Worker proxy the raw Request URL points at the
// upstream `.onrender.com` host. Better Auth (and the oauth-proxy plugin)
// need the original tenant/admin host to construct correct OAuth callback
// URLs and to decide whether to engage the proxy flow. Rebuild from
// X-Forwarded-Host / X-Forwarded-Proto when present.
let request = c.req.raw;
const fwdHost = c.req.header('x-forwarded-host');
const fwdProto = c.req.header('x-forwarded-proto');
if (fwdHost) {
const url = new URL(request.url);
url.host = fwdHost;
if (fwdProto === 'https' || fwdProto === 'http') {
url.protocol = `${fwdProto}:`;
}
request = new Request(url, request);
}
return auth.handler(request);
})
.get('/session', async (c) => {
const session = c.get('session');
const user = c.get('user');
+2 -1
View File
@@ -3,6 +3,7 @@ import { getActiveOrganizationPlan, getOrganizationById } from '@cio/db/queries/
import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@cio/db/queries/tag';
import { listWidgetCourses } from '@cio/db/queries/widget';
import { PLAN } from '@cio/utils/plans';
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
import {
buildWidgetPayload as buildWidgetPayloadShared,
normalizeWidgetConfig,
@@ -148,7 +149,7 @@ export function getCourseBaseUrl(siteName: string, customDomain?: string | null)
return `https://${customDomain}`;
}
return siteName ? `https://${siteName}.classroomio.com` : DEFAULT_WIDGET_APP_BASE_URL;
return siteName ? `https://${siteName}.${TENANT_ROOT_DOMAIN}` : DEFAULT_WIDGET_APP_BASE_URL;
}
export function getCourseWidgetScriptUrl(): string {
@@ -10,6 +10,7 @@
import { newOrgModal } from '../new-org-modal-store';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$features/org/api/org.svelte';
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
let orgName = $state('');
let siteName = $derived(generateSitename(orgName));
@@ -52,7 +53,7 @@
<Field.Field>
<Field.Label>{$t('add_org.org_sitename')}</Field.Label>
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".{TENANT_ROOT_DOMAIN}" />
{#if orgApi.errors.siteName || orgApi.errors.general}
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
{/if}
@@ -16,6 +16,7 @@
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
@@ -164,7 +165,7 @@
return;
}
if (sanitizedDomain.includes('classroomio.com')) {
if (sanitizedDomain.includes(BRAND_ROOT_DOMAIN) || sanitizedDomain.includes(TENANT_ROOT_DOMAIN)) {
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
return;
}
@@ -284,7 +285,7 @@
onchange={(e) => (siteName = (e.target as HTMLInputElement)?.value ?? '')}
placeholder="myschool"
prefix="https://"
suffix=".classroomio.com"
suffix=".{TENANT_ROOT_DOMAIN}"
/>
{#if errors.siteName}
<Field.Error>{errors.siteName}</Field.Error>
@@ -4,8 +4,13 @@
import { currentOrg } from '$lib/utils/store/org';
import { Badge } from '@cio/ui/base/badge';
import { PLAN_NAMES, PLAN } from '@cio/utils/plans';
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
const plan = $derived($currentOrg.plans?.[0]?.planName || PLAN.BASIC);
const utmSource = $derived(
$currentOrg.customDomain ||
($currentOrg.siteName ? `${$currentOrg.siteName}.${TENANT_ROOT_DOMAIN}` : BRAND_ROOT_DOMAIN)
);
</script>
<Sidebar.Menu>
@@ -16,7 +21,7 @@
>
{#snippet child({ props })}
<a
href="https://classroomio.com?utm_source={$currentOrg.siteName}.classroomio.com"
href="https://{BRAND_ROOT_DOMAIN}?utm_source={utmSource}"
target="_blank"
rel="noopener noreferrer"
{...props}
@@ -1,9 +1,10 @@
<script>
import { Badge } from '@cio/ui/base/badge';
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
</script>
<section id="student-dashboard" class="bg-black px-3 py-[10%] text-center text-white lg:px-0">
<Badge class="px-4! py-2! text-sm! underline">yourorg.classroomio.com</Badge>
<Badge class="px-4! py-2! text-sm! underline">yourorg.{TENANT_ROOT_DOMAIN}</Badge>
<h1 class="mx-auto flex flex-col items-center gap-4 text-3xl leading-[1.5] font-normal md:text-4xl lg:text-6xl">
Your Student Dashboard
</h1>
@@ -10,7 +10,7 @@
let { children } = $props();
</script>
<PageHeader className="flex flex-col items-center justify-center bg-[#F5F8FE] text-center pb-8">
<PageHeader className="flex flex-col items-center justify-center text-center pb-8">
<div class="mb-2 flex w-full items-center justify-center">
<span
class="rounded-full border-2 border-[#C2D2FF] bg-[#DCE5FF] px-4 py-1 text-sm font-medium text-blue-700"
@@ -10,7 +10,7 @@
let { children } = $props();
</script>
<PageHeader className="flex flex-col items-center justify-center bg-[#F5F8FE] text-center pb-8">
<PageHeader className="flex flex-col items-center justify-center text-center pb-8">
<div class="mb-2 flex w-full items-center justify-center">
<span
class="rounded-full border-2 border-[#C2D2FF] bg-[#DCE5FF] px-4 py-1 text-sm font-medium text-blue-700"
+1 -1
View File
@@ -67,7 +67,7 @@
class="flex w-full rounded-md border shadow-sm transition-all ease-in-out hover:scale-95 md:max-w-[370px]"
>
<img class="w-[30%] rounded-l-md" src={tool.src} alt="" />
<div class="w-[70%] rounded-r-md bg-[#F5F8FE] p-5">
<div class="w-[70%] rounded-r-md p-5">
<h1 class="text-sm md:text-base">{tool.title}</h1>
<p class="mt-1.5 text-xs text-gray-600">
{tool.subText}
+15 -3
View File
@@ -12,6 +12,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { config as emailAndPassword } from './auth/email-password';
import { getUserOrgRolesMap } from './queries/organization/organization';
import { loginLink } from './auth/plugins/login-link';
import { oAuthProxy } from 'better-auth/plugins/oauth-proxy';
import { resolveTrustedBrowserOrigin } from './utils';
import { sso } from '@better-auth/sso';
import { syncUserWithProfile } from './auth/hooks/sync-user';
@@ -77,9 +78,12 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
databaseHooks: {
user: {
create: {
after: async (user) => {
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
await createProfileHook(user);
after: async (user, ctx) => {
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id, ctx });
const request =
(ctx as { context?: { request?: Request }; request?: Request } | undefined)?.context?.request ??
(ctx as { request?: Request } | undefined)?.request;
await createProfileHook(user, request);
}
},
update: {
@@ -109,6 +113,14 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
// OIDC providers are registered dynamically per organization
// via the admin API (auth.api.registerSSOProvider)
}),
// Lets OAuth/SSO callbacks land on the canonical production URL
// (api.classroomio.com) while still completing the flow on the tenant
// host the user signed in from (<org>.classroomio.school or a BYOD
// domain). Without this, the state cookie set on the tenant host isn't
// sent to the api host on the Google redirect → state_security_mismatch.
oAuthProxy({
productionURL: CONSTANTS.BASE_URL
}),
loginLink(),
tokenExchange(),
// Attaches the user's org memberships ({ [orgId]: roleId }) to the session
+8 -3
View File
@@ -6,6 +6,7 @@ import { User } from 'better-auth';
import { db } from '@db/drizzle';
import { getOrganizationCount } from '@db/queries/organization';
import { ssoProvisioningHook } from './sso-provisioning';
import { tenantProvisioningHook } from './tenant-provisioning';
/**
* True if the user has any non-credential account (OAuth/SSO). Those providers verify email.
@@ -36,8 +37,8 @@ async function isSelfHostedFirstSignup(): Promise<boolean> {
*
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
*/
export const createProfileHook = async (user: User) => {
console.log('[auth] createProfileHook: running', { userId: user.id });
export const createProfileHook = async (user: User, request?: Request) => {
console.log('[auth] createProfileHook: running', { userId: user.id, request });
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
console.debug('existingProfile', existingProfile);
@@ -81,8 +82,12 @@ export const createProfileHook = async (user: User) => {
});
console.debug('newProfile', newProfile);
// Run SSO provisioning hook for JIT org membership
// Run SSO provisioning hook for JIT org membership (email-domain match)
await ssoProvisioningHook(user);
// Run tenant provisioning hook for signups on a tenant subdomain
// (signupGuard has already enforced disableSignup / inviteOnly)
await tenantProvisioningHook(user, request);
} catch (error) {
console.error('Error creating profile for user:', error);
}
@@ -0,0 +1,87 @@
import { User } from 'better-auth';
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN, blockedSubdomain } from '@cio/utils/constants';
import { getOrganizationByCustomDomain, getOrganizationBySiteName } from '@db/queries/organization';
import { ensureOrgMembership } from './sso-provisioning';
/**
* Subdomains under the brand/tenant root that are NOT a tenant org.
* Reuses the dashboard's `blockedSubdomain` list so the two stay in sync.
*/
const RESERVED_SUBDOMAINS = new Set<string>(blockedSubdomain);
/**
* Resolve the org a signup happened on, given the inbound request's host.
*
* Resolution order:
* 1. Subdomain under TENANT_ROOT_DOMAIN (e.g. ciodevs.classroomio.school) → siteName lookup
* 2. Subdomain under BRAND_ROOT_DOMAIN (e.g. ciodevs.classroomio.com) → siteName lookup
* 3. Anything else (e.g. learn.acme.com) → verified-custom-domain lookup
*
* Returns null for the apex, reserved subdomains (app/api/www/…), and unverified custom domains.
*/
async function resolveOrgIdFromHost(host: string): Promise<string | null> {
const hostname = host.split(':')[0]?.toLowerCase();
if (!hostname) return null;
const tenantSuffix = `.${TENANT_ROOT_DOMAIN}`;
const brandSuffix = `.${BRAND_ROOT_DOMAIN}`;
if (hostname.endsWith(tenantSuffix) || hostname.endsWith(brandSuffix)) {
const suffix = hostname.endsWith(tenantSuffix) ? tenantSuffix : brandSuffix;
const subdomain = hostname.slice(0, -suffix.length);
// Apex (no subdomain) or multi-level subdomain we don't recognise
if (!subdomain || subdomain.includes('.')) return null;
if (RESERVED_SUBDOMAINS.has(subdomain)) return null;
const org = await getOrganizationBySiteName(subdomain);
return org?.id ?? null;
}
const org = await getOrganizationByCustomDomain(hostname);
return org?.id ?? null;
}
/**
* Auto-enroll a signup into the org they signed up on.
*
* Runs after `ssoProvisioningHook` so SSO-domain matches (which carry policy-driven
* role mapping) win. `ensureOrgMembership` is idempotent and invite-aware, so if SSO
* already inserted a row this is a no-op.
*
* The `signupGuard` middleware (apps/api/src/middlewares/signup-guard.ts) has already
* rejected `disableSignup` orgs and `inviteOnly` orgs without a valid invite by the
* time this runs — so any signup that reaches the hook is allowed to join.
*
* Org resolution order:
* 1. `cio-org-id` request header (set by the dashboard on every API call)
* 2. Host header: tenant subdomain, brand subdomain, or verified custom domain.
* Prefers `x-forwarded-host` (set by the Cloudflare Worker proxy) over the
* raw `Host`, which behind the proxy is the upstream service name and not
* the tenant host the user actually visited.
*
* TODO(oauth): OAuth/SSO signups lose both the header and the original host —
* the callback lands on `api.classroomio.com/api/auth/callback/<provider>`, so
* neither path here can resolve the tenant. To fix, encode the org id in
* Better Auth's OAuth `state` (or `additionalParams`) at signup initiation and
* pull it out of `request` here. Until then, OAuth signups on a tenant site
* will not be auto-enrolled and must be invited manually.
*/
export const tenantProvisioningHook = async (user: User, request?: Request) => {
if (!user.email || !request) return;
const headerOrgId = request.headers.get('cio-org-id');
if (headerOrgId) {
await ensureOrgMembership(user.id, user.email, headerOrgId);
return;
}
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
if (!host) return;
const orgId = await resolveOrgIdFromHost(host);
if (!orgId) return;
await ensureOrgMembership(user.id, user.email, orgId);
};
@@ -197,6 +197,26 @@ export const getOrganizationBySiteName = async (siteName: string): Promise<TOrga
return organization || null;
};
/**
* Gets an organization by its verified custom domain.
* Only matches rows where isCustomDomainVerified is true so we never auto-enroll
* a signup into an org just because someone pointed DNS at us.
*/
export const getOrganizationByCustomDomain = async (customDomain: string): Promise<TOrganization | null> => {
const [organization] = await db
.select()
.from(schema.organization)
.where(
and(
eq(schema.organization.customDomain, customDomain.toLowerCase()),
eq(schema.organization.isCustomDomainVerified, true)
)
)
.limit(1);
return organization || null;
};
/**
* Gets an organization by ID
* @param id Organization ID
@@ -1,6 +1,7 @@
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
import { getVerifiedCustomDomainHostnames } from '../../queries/organization/organization';
const CLASSROOMIO_ROOT = 'classroomio.com';
const FIRST_PARTY_ROOTS: readonly string[] = [BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN];
/** Lowercase hostnames with verified custom domains ( warmed at API boot + updated on domain routes ). */
const verifiedCustomDomainHostnames = new Set<string>();
@@ -25,7 +26,7 @@ function originMatchesStaticEntry(origin: string, entry: string): boolean {
function isClassroomioHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return host === CLASSROOMIO_ROOT || host.endsWith(`.${CLASSROOMIO_ROOT}`);
return FIRST_PARTY_ROOTS.some((root) => host === root || host.endsWith(`.${root}`));
}
export function trustCustomDomainHostname(hostname: string): void {
@@ -1,3 +1,4 @@
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
import {
getWidgetThemeSerifStack,
type TWidgetConfig,
@@ -62,7 +63,7 @@ export function formatLessonsLine(course: TWidgetPayloadCourse): string {
return parts.join(' · ');
}
/** Hostname used in UTM (custom domain or `{siteName}.classroomio.com`). */
/** Hostname used in UTM (custom domain or `{siteName}.${TENANT_ROOT_DOMAIN}`). */
export function getOrgDomainForUtm(organization: TWidgetPayload['organization']): string {
const custom = organization.customDomain?.trim();
if (custom) {
@@ -76,7 +77,7 @@ export function getOrgDomainForUtm(organization: TWidgetPayload['organization'])
return custom.replace(/^\/+/, '').split('/')[0] || organization.siteName;
}
const site = organization.siteName.trim();
return site ? `${site}.classroomio.com` : 'classroomio.com';
return site ? `${site}.${TENANT_ROOT_DOMAIN}` : BRAND_ROOT_DOMAIN;
}
/** Marketing homepage link with widget + org attribution for analytics. */
@@ -89,5 +90,5 @@ export function buildPoweredByMarketingUrl(payload: TWidgetPayload): string {
utm_content: payload.widgetId,
utm_term: orgDomain
});
return `https://classroomio.com/?${params.toString()}`;
return `https://${BRAND_ROOT_DOMAIN}/?${params.toString()}`;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* The apex that hosts every free-tier tenant site as `<orgSiteName>.<TENANT_ROOT_DOMAIN>`.
* The `apps/cio-tenant-router` Cloudflare Worker terminates traffic for this zone and forwards
* to the dashboard service. Marketing apex (classroomio.com) is intentionally separate.
*/
export const TENANT_ROOT_DOMAIN = 'classroomio.school';
/** The marketing / admin / api zone. */
export const BRAND_ROOT_DOMAIN = 'classroomio.com';
+1
View File
@@ -1,4 +1,5 @@
export * from './roles';
export * from './org';
export * from './content';
export * from './domains';
export * from './error-codes';
+298
View File
@@ -0,0 +1,298 @@
# Onboarding Platform Showcase PRD
## Status
- Draft
## Date
- May 20, 2026
## Purpose
Redesign the new-user onboarding flow so creators see (a) what their public-facing **org landing page** can look like and pick a theme, and (b) land in a workspace that is already pre-populated with **goal-relevant demo courses** instead of an empty `/courses` page. The goal is to move first impression from "blank slate" to "this platform is alive and ready for me."
## Problem Statement
Today's onboarding is a sterile 2-step form:
1. Org name + slug + full name
2. Goal + source + locale
Then the user is dropped onto `/org/{slug}/courses?create=true` — a page with zero courses, zero branding decisions made, and no visible evidence that ClassroomIO is anything more than a CRUD form for a course list. Two problems compound:
1. **The landing page feature is invisible.** We ship 8 production-quality landing page themes (`minimal`, `bold`, `classic`, `saas`, `tech`, `studio`, `corporate`, `terminal`) with full preview PNGs, but new users never see them — they have to discover Settings → Landing Page on their own.
2. **The workspace looks empty.** New users have no reference for what a "real" ClassroomIO course looks like (sections, lessons, video embeds, certificates). They have to imagine the product from a blank canvas.
Result: low activation, high time-to-first-meaningful-action, and the impression that the platform is thinner than it actually is.
## ICP (Primary)
1. SaaS founders launching a product academy.
2. Independent course creators selling self-paced cohorts.
3. Operations leaders running employee/compliance training.
4. Customer-success teams running customer enablement.
## Target Jobs-To-Be-Done
1. Quickly evaluate whether ClassroomIO can produce a school site that looks like the creator's brand.
2. See what a "good" course structure looks like on the platform without having to build one to find out.
3. Get to a publishable-feeling state in a single session.
## Confirmed Product Decisions
1. **Theme picker** is a real step (not a passive preview gallery). The user's selection is persisted to `organization.landingPage.theme`.
2. **Auto-seed goal-relevant demo courses** — no extra picker step. The existing `goal` answer in metadata drives which course set lands in the org.
3. **Step order**: `org-setup → theme-picker (+ course preview) → user-metadata → seed-and-redirect`.
4. **Course source**: a **new curated demo set** authored in code (not the existing `seedCourses()` templates in `packages/db/src/utils/seed/course.ts`, which were built for E2E/dev seeding).
## Non-Goals (v1)
- AI-generated courses from a URL — that is a separate initiative tracked in `prd/onboarding-ai-bootstrap/`.
- Custom theme editing inside onboarding (colors, hero text, logo upload). The full landing-page editor stays in `/settings/landingpage`.
- Letting the user pick *which* demo courses to seed. Goal-mapping is opinionated.
- Multi-language demo course content. v1 demo courses ship in English only; the user's `locale` choice affects UI chrome but not seeded course text.
- Surfacing demo courses on the **public** landing page automatically — they seed as unpublished/draft so the org owner reviews first.
## Data Sources Checked
- `apps/dashboard/src/routes/(auth)/onboarding/+page.svelte`
- `apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts`
- `apps/dashboard/src/lib/features/onboarding/utils/constants.ts`
- `apps/dashboard/src/lib/features/onboarding/utils/types.ts`
- `apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte`
- `apps/dashboard/src/lib/features/onboarding/components/welcome-modal.svelte`
- `apps/dashboard/src/lib/features/org/utils/landing-page.ts`
- `apps/dashboard/src/lib/utils/types/org.ts`
- `apps/dashboard/src/lib/features/settings/pages/landingpage.svelte`
- `apps/dashboard/static/templates/*.png` (8 theme preview PNGs)
- `packages/ui/src/custom/org-landing-page/` (theme component implementations)
- `apps/api/src/routes/onboarding/onboarding.ts`
- `apps/api/src/services/onboarding.ts`
- `apps/api/src/services/course/course.ts` (createCourse pattern)
- `apps/api/src/services/course-import/course-import.ts` (bulk lesson/section seed pattern)
- `apps/api/src/routes/organization/course-import.ts`
- `packages/db/src/schema.ts` (organization, course, courseSection, lesson, courseImportDraft)
- `packages/db/src/utils/seed/course.ts` (existing dev seed, reference only)
- `packages/utils/src/validation/onboarding/onboarding.ts`
- `prd/onboarding-ai-bootstrap/README.md` (sibling initiative, must not conflict)
- `prd/org-landing-page-theme-picker [DONE]/README.md` (foundation shipped Apr 2026)
## Current Baseline (As-Is)
1. `ONBOARDING_STEPS = { ORG_SETUP: 1, USER_METADATA: 2 }` — two steps.
2. `createOrganizationWithOwner` creates the org with `landingPage = createDefaultLandingPageSettings()` (theme `'minimal'`, default hero copy from `defaultLandingPageHero` in `apps/dashboard/src/lib/features/org/utils/landing-page.ts:33`).
3. `updateUserOnboarding` saves goal/source/fullname, updates user locale, and the API class redirects to `/org/{siteName}?welcomePopup=...`.
4. No course creation happens during onboarding. The "create=true" query param on the courses page is unused.
5. Theme picker exists *only* in `/settings/landingpage` — never surfaced to first-time users.
6. The 8 theme preview PNGs at `apps/dashboard/static/templates/{theme}.png` are already shipped and used by the settings editor; we can reuse them as-is.
---
## Proposed Experience
### Step 1: Account + Org Basics (unchanged)
- `fullname`
- `orgName`
- `siteName`
On submit: existing `createOrganizationWithOwner` runs and the org is created with the default `'minimal'` theme.
### Step 2 (NEW): Theme Picker + Course Preview
Single screen, two stacked sections inside the existing centered onboarding card.
**Top section — "Pick a look for your school"**
- Responsive 4-up grid (collapsing to 2-up on small screens) of 8 theme cards.
- Each card: `/templates/{theme}.png` + theme display name + a subtle "Selected" ring when active.
- Default selection: `minimal` (matches what was already saved at org-create time).
- Clicking a card sets local `fields.theme`. No autosave — persistence happens on "Continue".
**Bottom section — "Your workspace will come pre-loaded"**
- 4 static course-tile mockups (using the same primitive `/courses` uses) showing variety: one `SELF_PACED`, one `LIVE_CLASS`, one `COMPLIANCE`, one with a certificate badge.
- Subtext: "We'll add a few sample courses you can edit, publish, or delete."
- These tiles are *previews only* — not the actual courses that will be seeded. They communicate "you're not landing in an empty room."
**Continue**: calls a new `POST /onboarding/select-theme` with `{ theme }`, then advances to step 3.
**Back**: returns to step 1 without losing org data (org is already persisted; back is just a step-state change).
### Step 3: User Metadata (unchanged in shape, behavior change on submit)
- `goal` (radio)
- `source` (radio)
- `locale` (select)
On submit: existing `updateUserOnboarding` runs, **then** the route invokes `seedDemoCoursesForGoal(orgId, profileId, goal)` synchronously (with a short timeout — see FR-4) before returning. Response includes `seededCourseIds` so the frontend can surface them in the post-redirect view.
### Post-Onboarding
- Redirect to `/org/{siteName}/courses` (drop the `?create=true` marker — it was unused and the page is no longer empty).
- The existing `welcomePopup` modal still fires and now references the seeded courses ("We added a few sample courses to get you started — feel free to edit or delete them.").
---
## Functional Requirements
### FR-1 — Theme Persistence
- `POST /onboarding/select-theme` accepts `{ theme: OrgLandingPageTheme }`, validates against `landingPageThemes` (8 values), and patches `organization.landingPage.theme` using a `jsonb_set` so other landing-page fields (hero, navItems, footer) remain untouched.
- Endpoint requires `authMiddleware`. Caller must be the owner of the org tied to the current onboarding session.
- Idempotent — re-submitting the same theme is a no-op success; re-submitting a different theme overwrites.
### FR-2 — Curated Demo Course Set
- Define `DEMO_COURSES_BY_GOAL: Record<OnboardingGoal, DemoCourseSpec[]>` in `packages/db/src/utils/demo-courses/index.ts`.
- Each `DemoCourseSpec` includes: `title`, `description`, `overview` (markdown), `type` (`SELF_PACED` | `LIVE_CLASS` | `COMPLIANCE` | `PUBLIC`), `bannerImage` (URL to a static asset), optional `certificate` config, and a `sections: Array<{ title, lessons: Array<{ title, note, videos? }> }>` tree.
- Lesson content: title + ~150-word markdown `note` + optional public YouTube embed reference. No file uploads required.
- Target size: 2 courses per goal, 3 sections per course, 3 lessons per section — enough to look real, cheap to seed.
- Mapping (initial):
- `sell-online` → "Launch Your First Paid Course" (`SELF_PACED`), "Marketing Funnels for Course Creators" (`SELF_PACED`)
- `teach-online` → "Designing Engaging Live Cohorts" (`LIVE_CLASS`), "Active Learning Toolkit" (`SELF_PACED`)
- `employees` → "Workplace Compliance 101" (`COMPLIANCE`, with certificate), "Manager Essentials" (`SELF_PACED`)
- `customers` → "Customer Onboarding Bootcamp" (`SELF_PACED`), "Product Mastery" (`PUBLIC`)
- `expanding-platform` → one of each `type` (broadest exposure)
### FR-3 — Demo Course Seeding
- New service `seedDemoCoursesForGoal(orgId, profileId, goal)` in `apps/api/src/services/onboarding/seed-demo-courses.ts`.
- Internally calls a new query helper `createDemoCoursesForOrg(orgId, ownerProfileId, specs)` in `packages/db/src/queries/onboarding/demo-courses.ts` that runs **inside a single transaction**, mirroring the insert shape used by `createCourse` (`apps/api/src/services/course/course.ts:181`) — group + course + group-member + default section + welcome newsfeed — extended for the additional sections/lessons.
- Seeded courses are created with `isPublished: false` and `isTemplate: false` so they appear as drafts the owner can edit/publish/delete.
- **Idempotency**: before seeding, the service counts the org's non-template courses; if `>= 1`, it skips. This guards against double-submit of `/update-metadata`.
### FR-4 — Seed Failure Doesn't Block Redirect
- The `/update-metadata` handler awaits `seedDemoCoursesForGoal` with a 5-second timeout.
- On timeout or thrown error: log structured warning (`onboarding.seed_demo_courses.failed` with `orgId`, `goal`, `error`), return `{ success: true, data: { ...metadata, seededCourseIds: [] } }` so the user is not stranded. A background retry is **not** in scope for v1.
### FR-5 — Theme Picker UI
- New `apps/dashboard/src/lib/features/onboarding/components/theme-picker.svelte`.
- Renders the 8-theme grid + the 4-tile preview block.
- Theme list is sourced from `landingPageThemes` in `apps/dashboard/src/lib/features/org/utils/landing-page.ts:22` (single source of truth).
- Theme labels are translation keys: `onboarding.theme.{themeId}`.
- Preview tiles reuse the existing course-card primitive (the one rendered at `/courses` — verify the component name during implementation; if multiple exist, pick the one used in the published course list).
### FR-6 — Step Constants & Progress Bar
- Update `ONBOARDING_STEPS` to `{ ORG_SETUP: 1, THEME_PICKER: 2, USER_METADATA: 3 }`.
- The existing progress bar (`apps/dashboard/src/routes/(auth)/onboarding/+page.svelte:24`) uses `Object.keys(ONBOARDING_STEPS).length`, so it auto-scales.
- Back button is enabled on steps 2 and 3 (currently only enabled when `step > ORG_SETUP`).
### FR-7 — Validation
- Add `ZOnboardingSelectTheme` in `packages/utils/src/validation/onboarding/`:
```ts
export const ZOnboardingSelectTheme = z.object({
theme: z.enum(LANDING_PAGE_THEMES)
});
```
- `LANDING_PAGE_THEMES` must be a single shared literal exported once (either from `@cio/ui/custom/org-landing-page/types` or `@cio/utils`) and reused by both the validator and the frontend `landingPageThemes` array.
### FR-8 — Frontend Type Hygiene
- Per CLAUDE.md frontend conventions:
- All request/response types live in `apps/dashboard/src/lib/features/onboarding/utils/types.ts`, inferred from the API (`typeof classroomio.onboarding['select-theme'].$post`).
- **No** type definitions in `.svelte.ts` files.
- All copy lives in `apps/dashboard/src/lib/utils/translations/en.json` under `onboarding.theme.*`.
### FR-9 — Observability
- Emit funnel events (whatever analytics layer the dashboard currently uses — verify before instrumenting):
- `onboarding.theme_picker_viewed`
- `onboarding.theme_selected` (with `theme` property)
- `onboarding.demo_courses_seeded` (with `goal`, `count`, `latency_ms`)
- `onboarding.demo_courses_seed_failed` (with `goal`, `error`)
- These are additive; do not remove existing onboarding analytics.
### FR-10 — Self-Host Safety
- The demo-course seed payload is a static constant in `@cio/db`. Self-hosted instances get the same behavior with no external dependencies (no LLM calls, no image hosting beyond static assets bundled with the dashboard).
- Default theme `'minimal'` continues to be applied at org-create time, so users who skip step 2 (e.g. close the tab) still end up with a working landing page.
---
## Proposed API + Service Design
### Validation — `packages/utils/src/validation/onboarding/`
- `ZOnboardingSelectTheme` — see FR-7.
- `ZOnboardingUpdateMetadata` — **no shape change**; behavior change is server-side only.
### Routes — `apps/api/src/routes/onboarding/onboarding.ts`
- `POST /onboarding/select-theme` *(new)* — auth + zValidator(ZOnboardingSelectTheme); delegates to `selectOnboardingTheme(profileId, orgId, theme)`.
- `POST /onboarding/update-metadata` *(extended)* — after `updateUserOnboarding`, awaits `seedDemoCoursesForGoal(orgId, profileId, goal)` with timeout/log-on-fail behavior in FR-4. Returns `{ success: true, data: { ...existing, seededCourseIds } }`.
### Services — `apps/api/src/services/onboarding/`
- `select-theme.ts` — `selectOnboardingTheme(profileId, orgId, theme)`. Authorizes profile is owner; calls query helper.
- `seed-demo-courses.ts` — `seedDemoCoursesForGoal(orgId, profileId, goal)`. Idempotency check + delegate to query helper.
### Queries — `packages/db/src/queries/`
- `organization/landing-page.ts` — `updateOrgLandingPageTheme(orgId, theme)` using `jsonb_set`.
- `onboarding/demo-courses.ts` — `createDemoCoursesForOrg(orgId, profileId, specs)` running in a single transaction.
### Data — `packages/db/src/utils/demo-courses/index.ts`
- Static export `DEMO_COURSES_BY_GOAL` + types. Lives in `@cio/db` so both API services and (future) self-host migration tools can import it.
### Dashboard — `apps/dashboard/src/lib/features/onboarding/`
- `utils/constants.ts` — add `THEME_PICKER` step.
- `utils/types.ts` — extend `OnboardingField` with `theme?: OrgLandingPageTheme`; add request type for the new endpoint.
- `api/onboarding.svelte.ts` — add `submitTheme(theme)` method; update step dispatcher; advance to `USER_METADATA` on success.
- `components/theme-picker.svelte` *(new)* — the new step UI.
- `routes/(auth)/onboarding/+page.svelte` — extend the if/else block to a 3-way switch; wire back-button to step down through all 3 states.
---
## Data Model Changes
**None at schema level.** Both new behaviors reuse existing tables:
- Theme selection writes to the existing `organization.landingPage` jsonb column.
- Demo courses use the existing `course`, `courseSection`, `lesson` tables (no new columns).
No migration is required. (Aligns with the user's standing rule: schema edits stop at a passing `@cio/db` build; migrations are handled outside this workflow.)
---
## Translation Keys (new)
Under `onboarding.theme.*` in `apps/dashboard/src/lib/utils/translations/en.json`:
- `heading` — "Pick a look for your school"
- `subheading` — "You can change this anytime in settings."
- `demo_heading` — "Your workspace will come pre-loaded"
- `demo_subheading` — "We'll add a few sample courses you can edit, publish, or delete."
- `minimal`, `bold`, `classic`, `saas`, `tech`, `studio`, `corporate`, `terminal` — display labels.
---
## UX Quality Bar
Per the user's standing UI rules:
- Use `@cio/ui/base/card` for theme tiles, with `ui:ring-primary` for selected state (color tokens prefixed `ui:` for theme resolution).
- Use the same course-card primitive `/courses` uses for the preview tiles — do not invent a new card. If a suitable component does not exist, extract one rather than copy-pasting markup.
- No hardcoded user-facing strings.
- No native `<input>` / `<label>` in this UI; use `@cio/ui/custom/*-field` wrappers if any inputs are needed (none required for v1, but applies if scope grows).
---
## Rollout Plan
1. **Phase 1 — internal alpha (week 1)**: ship behind a soft "feature flag" by gating step 2 on an env var (`PUBLIC_ONBOARDING_SHOWCASE_ENABLED`) so we can test in staging without exposing to prod signups.
2. **Phase 2 — 25% cloud rollout (week 2)**: bucket by user ID hash.
3. **Phase 3 — 100% cloud (week 3, assuming no regression)**: remove the env gate; legacy 2-step flow code is deleted.
4. **Phase 4 — self-host**: ships with the next normal release; no toggle needed (no external dependencies).
---
## Success Metrics
1. **+15% relative lift in onboarding completion** (step-3 submit / step-1 view).
2. **+30% relative lift in "user views the public landing page in their first session"** — proxy for "did the theme picker land?"
3. **+50% relative lift in "user edits at least one course in their first session"** — proxy for the seeded courses being a real lure into the editor.
4. **Median onboarding time stays under 4 minutes** — adding a step must not bloat the flow.
5. **<2% seed-failure rate** measured by `onboarding.demo_courses_seed_failed` over `onboarding.theme_selected`.
---
## Risks and Mitigations
1. **Risk**: Seeded demo courses confuse users who think they're real templates they have to keep. **Mitigation**: courses ship as `isPublished: false` drafts; welcome popup copy explicitly says "feel free to edit or delete"; a small "Sample" badge on the course card is a v1.1 candidate.
2. **Risk**: Demo course content drifts and looks dated. **Mitigation**: content is in code (`DEMO_COURSES_BY_GOAL`) and reviewed every release; quarterly content refresh is owned by the content team.
3. **Risk**: Seed transaction is slow on large goal sets (5+ courses × 9 lessons each = lots of inserts). **Mitigation**: bulk insert with a single transaction; cap demo set at 2 courses × 3 sections × 3 lessons; FR-4 timeout protects the user-facing path.
4. **Risk**: Goal value drift (someone adds a 6th goal to `GOALS`) breaks the `DEMO_COURSES_BY_GOAL` lookup. **Mitigation**: TypeScript `Record<OnboardingGoal, ...>` makes this a compile-time error; CI catches it.
5. **Risk**: Conflict with the parallel `onboarding-ai-bootstrap` PRD. **Mitigation**: that PRD adds a *website URL* step and AI-generated courses; this PRD's theme step and curated demo set are orthogonal and can stack later (AI courses replace demo courses when AI flow runs; theme picker is shared). Document in `prd/onboarding-ai-bootstrap/README.md` once both are merged.
6. **Risk**: Theme choice in onboarding gets overwritten by the default-applied theme on org create. **Mitigation**: org create still applies `'minimal'` default; the new `/select-theme` endpoint runs after and uses `jsonb_set` to patch only the theme field — confirmed by manual E2E in the verification plan.
---
## Open Questions
1. Should the preview tiles in step 2 be **personalized** with the org's just-entered name (e.g. "Welcome to {orgName}") to feel less canned? Adds complexity for marginal trust gain.
2. Should the welcome popup link directly to one of the seeded courses to encourage immediate editing, vs. dropping the user on the courses list?
3. Should `locale != 'en'` users see a banner saying "Sample courses are in English — translate or replace as you like" instead of getting English-language content silently?
4. Do we want to display a "Sample" pill on the seeded courses in the course list so the owner remembers what's editable demo content? (Recommendation: yes, but ship in v1.1.)
5. Should the theme picker also let the user upload a logo? (Recommendation: no — keep scope tight; logo upload lives in settings.)
---
## Verification Plan
1. **Build**: `pnpm --filter @cio/db build && pnpm --filter @cio/api build && pnpm --filter @cio/dashboard check`
2. **Manual E2E**, fresh user per run:
- Sign up → confirm step 1 looks unchanged.
- Step 2: 8 themes render with correct preview PNGs; selection ring follows clicks; "Continue" persists theme.
- Step 3: submit metadata → confirm redirect to `/org/{slug}/courses` populated with 2 demo courses matching the chosen goal.
- Public landing page `/{slug}` renders the picked theme (not `minimal`).
- Settings → Landing Page reflects the picked theme.
- Open a seeded course → confirm sections, lessons, and lesson notes render.
3. **Goal coverage**: run the E2E once per `goal` value (5 runs) — confirm correct course set per goal.
4. **Idempotency**: replay `POST /onboarding/update-metadata` via dev tools — confirm no duplicate courses created.
5. **Seed failure path**: temporarily throw inside `createDemoCoursesForOrg` — confirm user still completes onboarding and lands on `/courses` (empty but functional), and that `onboarding.demo_courses_seed_failed` log line emits.
6. **Theme override**: change theme via back button in step 2 multiple times — last selection wins.