From 5a92e1168b0e8ab58bfbc11a83dbe1c50f7c5945 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 22:24:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20M1=20=E2=80=94=20data=20mod?= =?UTF-8?q?el=20+=20endpoints=20+=20client=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.users: new nullable `onboarding_completed_at` column - new /api/v1/me/onboarding routes: GET, POST /complete, PATCH /reset - onboardingStatus Svelte store in the web app that reads/writes via those endpoints (no JWT claim so completing the flow takes effect without a token re-mint) - docs/plans/onboarding-flow.md adjusted: no backfill (launch without existing users), better-auth `name` clarified, 7 templates including "Arbeit" confirmed Foundation for the 3-screen first-login flow (Name → Look → Templates). No UI and no route guard yet — those ship in M2 when the redirect target actually exists. Schema change is a pure column-add, applied via `pnpm --filter @mana/auth db:push`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/stores/onboarding-status.svelte.ts | 105 ++++++ docs/plans/onboarding-flow.md | 320 ++++++++++++++++++ services/mana-auth/src/db/schema/auth.ts | 4 + services/mana-auth/src/index.ts | 6 + services/mana-auth/src/routes/onboarding.ts | 69 ++++ 5 files changed, 504 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/stores/onboarding-status.svelte.ts create mode 100644 docs/plans/onboarding-flow.md create mode 100644 services/mana-auth/src/routes/onboarding.ts diff --git a/apps/mana/apps/web/src/lib/stores/onboarding-status.svelte.ts b/apps/mana/apps/web/src/lib/stores/onboarding-status.svelte.ts new file mode 100644 index 000000000..287f26a7f --- /dev/null +++ b/apps/mana/apps/web/src/lib/stores/onboarding-status.svelte.ts @@ -0,0 +1,105 @@ +/** + * Onboarding status store — mirrors the `onboardingCompletedAt` column + * from `mana-auth.auth.users`. + * + * Kept separate from `authStore`/`userSettings` because: + * 1. It changes at most twice in a user's lifetime (complete, reset), + * so folding it into the hot-path auth state is noise. + * 2. We want a dedicated endpoint so finishing the flow takes effect + * without re-minting the JWT (see docs/plans/onboarding-flow.md). + * + * Used by the `(app)` layout guard (M2+) to redirect new users into + * `/onboarding/name`, and by the `templates/+page.svelte` finish handler + * to mark the flow done. + */ + +import { browser } from '$app/environment'; +import { authStore } from './auth.svelte'; + +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_AUTH_URL__?: string }) + .__PUBLIC_MANA_AUTH_URL__; + if (injected) return injected; + } + return import.meta.env.DEV ? 'http://localhost:3001' : ''; +} + +type Status = { completedAt: Date | null }; + +function parseStatus(raw: { completedAt: string | null }): Status { + return { completedAt: raw.completedAt ? new Date(raw.completedAt) : null }; +} + +function createOnboardingStatusStore() { + let completedAt = $state(null); + let loaded = $state(false); + let loading = $state(false); + + async function authedFetch(path: string, init?: RequestInit): Promise { + const token = await authStore.getValidToken(); + if (!token) throw new Error('Not authenticated'); + return fetch(`${getAuthUrl()}/api/v1/me/onboarding${path}`, { + ...init, + headers: { + ...(init?.headers ?? {}), + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + } + + return { + get completedAt() { + return completedAt; + }, + get loaded() { + return loaded; + }, + get loading() { + return loading; + }, + /** True when the user still needs to go through the flow. */ + get needsOnboarding() { + return loaded && completedAt === null; + }, + + async load(): Promise { + if (!browser || loading) return; + loading = true; + try { + const res = await authedFetch('/'); + if (!res.ok) throw new Error(`GET /onboarding → ${res.status}`); + const data = (await res.json()) as { completedAt: string | null }; + ({ completedAt } = parseStatus(data)); + loaded = true; + } catch (err) { + // A failed load must not hard-block rendering — the guard + // treats `loaded === false` as "not sure yet, don't + // redirect". Logged so a live issue is visible in telemetry. + console.warn('[onboarding-status] load failed:', err); + } finally { + loading = false; + } + }, + + async markComplete(): Promise { + if (!browser) return; + const res = await authedFetch('/complete', { method: 'POST' }); + if (!res.ok) throw new Error(`POST /onboarding/complete → ${res.status}`); + const data = (await res.json()) as { completedAt: string | null }; + ({ completedAt } = parseStatus(data)); + loaded = true; + }, + + async reset(): Promise { + if (!browser) return; + const res = await authedFetch('/reset', { method: 'PATCH' }); + if (!res.ok) throw new Error(`PATCH /onboarding/reset → ${res.status}`); + completedAt = null; + loaded = true; + }, + }; +} + +export const onboardingStatus = createOnboardingStatusStore(); diff --git a/docs/plans/onboarding-flow.md b/docs/plans/onboarding-flow.md new file mode 100644 index 000000000..17fc0508e --- /dev/null +++ b/docs/plans/onboarding-flow.md @@ -0,0 +1,320 @@ +# Onboarding Flow + +**Status:** proposed +**Scope:** UX + auth + workbench data-layer +**Owner:** till +**Created:** 2026-04-23 + +## Problem + +Mana launcht bald mit 27+ Modulen in einer einzigen App. Ein Erstnutzer +landet heute nach Signup auf `/welcome` (localStorage-Flag +`HAS_SEEN_WELCOME`), sieht eine generische Intro, und wird dann in eine +hart kodierte Home-Scene mit `todo/calendar/notes` fallen gelassen. + +Effekte: + +- Nichts fragt, **wer** der Nutzer ist — jede Greeting bleibt anonym. +- Nichts fragt, **was** er mit Mana tun will — wer für Fitness-Tracking + kam, sieht Work-Defaults und bounct; wer für Journaling kam, findet + das Modul nur über die Gallery. +- Nichts macht die App visuell „seins" — die 8 vorhandenen + Theme-Varianten sind ein starkes Differenzierungs-Feature, das erst + in Settings zu finden ist. + +## Goals + +- **3-Screen-Onboarding** nach Signup: Name → Look → Templates. +- `displayName` dauerhaft in mana-auth persistieren, damit UI und + Sync-Jobs einen Handle haben. +- Nutzer wählt ein Theme aus den 8 vorhandenen Varianten als erstes + „make it yours"-Moment. +- Nutzer wählt 1+ Use-Case-Templates (Health, Lernen, Sport, Entdecken, + Erinnern, Alltag); deren Union bestimmt, welche Module als Cards in + der Default-Home-Scene gepinnt werden. +- Jederzeit überspringbar (pro Screen + „alles überspringen"), mit + sinnvollen Fallbacks. + +## Non-goals + +- **Kein Space-Type-Picker im Flow.** Nutzer starten im automatisch + angelegten Personal-Space; Family/Team/Brand/Club/Practice bleiben + hinter dem existierenden Space-Creation-Flow — v2-Thema. +- **Kein Avatar-Upload.** Me-Images hat seinen eigenen Flow + (`/profile/me-images`) und würde das Onboarding sprengen. +- **Kein Per-Modul-Onboarding.** Module wie `news` haben eigene + `onboardingCompleted`-Flags und bleiben orthogonal. +- **Keine dynamische/LLM-generierte Template-Empfehlung** in v1 — + statischer Katalog ist inspectable, testbar, in einer Woche shipbar. + +## Current state + +- **Signup → `/welcome`** (`apps/mana/apps/web/src/routes/welcome/+page.svelte:19`). + `HAS_SEEN_WELCOME` im localStorage: client-only, nicht cross-device, + nicht auto-disabled nach einmaligem Sehen. +- **AuthUser-Shape**: Better-Auth nutzt `user.name` (nicht + `displayName`) als primäres Feld (`services/mana-auth/src/db/schema/auth.ts:40`); + Client-side `UserData.name` (`packages/shared-auth/src/types/index.ts:75`) + spiegelt das wider, ist aber optional weil nicht jeder Flow ihn + gesetzt hat. +- **SpaceTypes**: `personal | brand | club | family | team | practice` + (`packages/shared-types/src/spaces.ts:29`). Personal-Space wird via + Sentinel `_personal:` automatisch angelegt + (`apps/mana/apps/web/src/lib/data/scope/bootstrap.ts:24`). +- **Theme-Store**: + `createThemeStore({appId:'mana', defaultVariant:'ocean'})` + (`apps/mana/apps/web/src/lib/stores/theme.ts:18`). 8 Varianten: + `lume | nature | stone | ocean | sunset | midnight | rose | lavender`. + Settings synced über `GlobalSettings.theme` mit `mana-auth`. +- **Home-Scene-Default**: hart kodiert + (`apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts:47`): + ```ts + const DEFAULT_HOME_APPS = [{appId:'todo'},{appId:'calendar'},{appId:'notes'}]; + ``` +- **`workbenchScenesStore.createScene()`** existiert schon und ist + deterministisch genug, um aus dem Onboarding heraus eine + alternative Start-Scene zu schreiben. +- **App-Registry**: + `apps/mana/apps/web/src/lib/app-registry/apps.ts` + + `packages/shared-branding/src/mana-apps.ts` halten 27+ Module mit + Icons, Tiers und Kurzbeschreibungen. + +## Design + +### Flow + +``` +signUp → /onboarding/name → /onboarding/look → /onboarding/templates → / +``` + +Route-Guard im Root-Layout: authentifizierter User + `onboardingCompletedAt === null` +→ redirect auf `/onboarding/name`, **außer** man ist bereits auf einer +`/onboarding/*`-Route. + +### Screen 1 — Name + +- **Headline**: „Wie willst du genannt werden?" +- Text-Input (1–40 chars, trim), Placeholder greifbar („z. B. Till") +- „Weiter"-Button disabled bei leerem Wert +- „Überspringen" oben rechts → fallback `name = email.split('@')[0]` +- Submit: POST `mana-auth` → aktualisiert Better-Auth `user.name` (z. B. + über `authClient.updateUser({ name })` oder einen eigenen Endpoint) +- Keep simple: Enter = Weiter + +### Screen 2 — Look + +- **Headline**: „Hi {displayName}, wähle deinen Look" +- 8 Theme-Tiles mit Live-Preview (Mini-Workbench-Stack in dem Variant) +- Oben: Mode-Toggle `Hell | Dunkel | System` als separate Row +- Single-Select, Klick setzt direkt: + - `theme.setVariant(variant)` (live, lokal) + - `userSettings.updateGlobal({ theme: { mode, colorScheme } })` (persistent) +- „Weiter" immer aktiv (Default vorselektiert: aktueller Wert oder `ocean`) + +### Screen 3 — Templates + +- **Headline**: „Wofür willst du Mana nutzen?" +- 7 Tiles (Multi-Select, Icon + Name + 1-Liner): + + | Template | Module-Vorschlag (Reihenfolge = Priorität) | + |----------------|---------------------------------------------------------------| + | **Alltag** | `todo`, `calendar`, `notes`, `contacts` | + | **Arbeit** | `todo`, `calendar`, `mail`, `chat`, `times`, `notes` | + | **Health** | `habits`, `body`, `mood`, `food`, `period` | + | **Sport** | `habits`, `body`, `food`, `goals`, `stretch` | + | **Lernen** | `skilltree`, `quiz`, `notes`, `library`, `kontext` | + | **Entdecken** | `places`, `citycorners`, `photos`, `music`, `wetter` | + | **Erinnern** | `memoro`, `journal`, `photos`, `moodlit`, `quotes` | + + Alle Modul-IDs gegen `apps/mana/apps/web/src/lib/app-registry/apps.ts` + verifiziert (2026-04-23). Dedup über Templates hinweg passiert im + Finish-Handler; Prioritäts-Reihenfolge bleibt erhalten. + +- „Fertig"-Button: + 1. Union der `moduleIds` deduplicaten, Reihenfolge = erstes + Vorkommen in Template-Priorität + 2. Cap bei **8 Modulen** (2×4 Grid) — Rest bleibt über „App + hinzufügen" erreichbar + 3. `workbenchScenesStore.createScene({ name: 'Zuhause', apps })` — + überschreibt `DEFAULT_HOME_APPS` für diesen User + 4. PATCH `onboardingCompletedAt = now()` + 5. Redirect `/` +- „Überspringen": Scene wird **nicht** erstellt; fällt zurück auf + `DEFAULT_HOME_APPS`. `onboardingCompletedAt` wird **trotzdem** + gesetzt (sonst Flow-Schleife). + +### Data changes + +**mana-auth**: neues Feld `onboardingCompletedAt: Date | null` auf +`auth.users`-Tabelle (nullable, default null). Kein Backfill nötig — +Launch ohne bestehende Nutzer. + +Migration: einfache Drizzle-Schema-Erweiterung. `pnpm --filter @mana/auth +db:push` wendet den Column-Add an, keine hand-authored SQL nötig. + +**Read-Path**: dedizierter Endpoint +`GET /api/v1/me/onboarding → { completedAt: Date | null }` statt JWT- +Claim, damit wir nach `POST /complete` nicht den Token neu minten +müssen. Client-seitiger Store lädt bei Auth-Ready einmalig. + +**Write-Path**: +- `POST /api/v1/me/onboarding/complete` → setzt `onboardingCompletedAt = now()`, + idempotent (kein Update wenn bereits gesetzt) +- `PATCH /api/v1/me/onboarding/reset` → setzt auf `null`, für das + Settings-Re-Trigger in M5 + +**Shared-branding**: neue Datei +`packages/shared-branding/src/onboarding-templates.ts`: + +```ts +export type OnboardingTemplateId = 'alltag' | 'arbeit' | 'health' | 'sport' | 'lernen' | 'entdecken' | 'erinnern'; + +export type OnboardingTemplate = { + id: OnboardingTemplateId; + name: string; // DE + shortDescription: string; // 1-Liner + icon: IconName; // aus @mana/shared-icons + moduleIds: string[]; // max 5, Reihenfolge = Priorität +}; + +export const ONBOARDING_TEMPLATES: readonly OnboardingTemplate[] = [...]; +``` + +Warum `shared-branding`, nicht webapp-lokal: die Template-Definition +ist Branding/Produkt-Entscheidung, nicht UI-Logik — memoro (mobile) +kann sie später mit-konsumieren. + +### Route-Struktur + +``` +apps/mana/apps/web/src/routes/onboarding/ +├── +layout.svelte # Progress-Dots (3), „alles überspringen" +├── +layout.ts # Guard: onboardingCompletedAt gesetzt → redirect / +├── name/+page.svelte +├── look/+page.svelte +└── templates/+page.svelte +``` + +### Reuse, don't rebuild + +- **Theme-Picker**: Die Tiles aus `/themes` extrahieren in + `ThemeSelector.svelte`, in beiden Screens mounten. +- **Module-Tiles** nutzen `APP_ICONS` aus + `packages/shared-branding/src/app-icons.ts` + `mana-apps.ts` für + Namen/Beschreibung — kein Duplikat. +- **Scene-Creation** nutzt `workbenchScenesStore.createScene()` — kein + neues Data-Layer-Primitiv. +- **Settings-Sync**: `userSettings.updateGlobal()` existiert schon und + synct über mana-auth. + +## Implementation steps + +**M1 — Data model + Backend + Client-Store** (~1 Tag) +- Drizzle: `onboardingCompletedAt` column auf `auth.users` +- Endpoints: GET/POST/PATCH unter `/api/v1/me/onboarding/*` +- Client-Store: `$lib/stores/onboarding-status.svelte.ts` mit + `load()`/`markComplete()`/`reset()` +- **Kein Route-Guard, kein UI** — Plumbing only, kann ohne + Feature-Flag mergen. Guard kommt in M2 wenn Screens existieren + (sonst würde Redirect auf 404 zeigen). + +**M2 — Route-Guard + Shell + Screen 1 (Name)** (~0.5 Tag) +- Guard in `(app)/+layout.svelte` → `goto('/onboarding/name')` wenn + `onboardingStatus.completedAt === null` und Pfad nicht schon + `/onboarding/*` +- `/onboarding/+layout.svelte` mit Progress-Dots + Skip-All, Haupt- + Chrome (PillNav/Bottom-Stack) ausblenden für clean UI +- `/onboarding/name/+page.svelte` + `authClient.updateUser({ name })` +- E2E: Signup → Name-Screen → Weiter → `/onboarding/look` + +**M3 — Screen 2 (Look)** (~0.5 Tag) +- `ThemeSelector.svelte` aus `/themes` extrahieren +- `/onboarding/look/+page.svelte`, Persistenz via + `userSettings.updateGlobal({theme})` +- E2E: Theme-Klick → CSS-Variablen wechseln live → reload → Theme bleibt + +**M4 — Templates + Screen 3** (~1 Tag) +- `ONBOARDING_TEMPLATES` definieren, Modul-IDs gegen Registry + verifizieren (siehe Open Question 1) +- `/onboarding/templates/+page.svelte` mit Multi-Select-Tiles +- Finish-Handler: dedupliziertes Modul-Set → `createScene()`, + `onboardingCompletedAt = now()`, Redirect `/` +- E2E: Health + Sport picken → Home-Scene enthält + `habits, body, food, mood, period, goals` (dedupliziert, max 8) + +**M5 — Polish + Re-Trigger** (~0.5 Tag) +- Settings → Account → „Onboarding erneut durchlaufen" setzt + `onboardingCompletedAt = null` +- `/welcome`-Seite: weiter als Marketing-Landing behalten **oder** + deprecaten (TBD, offene Frage) +- Analytics-Events: `onboarding_screen_viewed`, + `onboarding_template_picked`, `onboarding_completed`, + `onboarding_skipped_at_{name|look|templates}` + +**Total: ~3.5 Tage** bei realistischer Schätzung. Parallelisierbar zu +zweit auf ~2 Tage (M1 einer, M2+M3 parallel, M4 sobald M1 durch). + +## Tradeoffs + +- **3 Screens ist am oberen Ende.** Jeder Screen ist ein Drop-off-Risk. + Kompensiert durch Skip pro Screen + Defaults. Schnellster Path ist + 3× Enter. +- **Multi-Select vs. Exklusiv** bei Templates: Multi-Select gewinnt, + weil Health+Sport oder Lernen+Alltag echte reale Kombis sind. + Dedup ist billig, keine UX-Kosten. +- **Statisch vs. LLM-generiert**: Statisch ist inspectable, testbar, + Fehler-armer erster Eindruck. LLM-Variante ist v2. +- **Theme in Onboarding vs. Settings-only**: Risiko, dass Nutzer + overwhelmed sind von 8 Varianten bei Signup. Mitigation: ein + sinnvolles Default (`ocean`) ist vorselektiert, „Weiter" ohne Klick + ist OK. +- **Templates in `shared-branding`**: minimaler Lock-in für memoro + (mobile). Alternative: web-lokal bis jemand mobile-Onboarding baut. + `shared-branding` gewinnt — Definition ist Produkt, nicht UI. +- **Route-Guard im Root-Layout vs. Hook in jedem Module**: Root-Layout + zentralisiert Redirect-Logik, eine Stelle zum Ändern. Hook pro + Modul wäre defensiver, aber N-mal Duplicate. Root gewinnt. + +## Rollout + +- Feature-Flag `ONBOARDING_V1_ENABLED` (default off in prod) +- M1–M4 Bundle mergen, dann Flag auf Staging flippen, smoketesten +- Bestehende User sind durch Backfill geschützt — nur Neu-Signups + nach Flag-on sehen den Flow +- Drop-off-Monitoring pro Screen; falls > 30% an einer Stelle → + Screen-Inhalt kürzen oder Skip prominenter machen +- Settings-Re-Trigger (M5) lässt Early-Adopter freiwillig durchlaufen +- Live-Flip nach ~3 Tagen Staging-Soak ohne kritische Findings + +## Resolved decisions (2026-04-23) + +- **Modul-IDs verifiziert** gegen `apps.ts`. Alle 29 in Templates + referenzierten IDs existieren: `todo, calendar, notes, contacts, + mail, chat, times, habits, body, mood, food, period, goals, stretch, + skilltree, quiz, library, kontext, places, citycorners, photos, + music, wetter, memoro, journal, moodlit, quotes`. +- **„Arbeit" als eigenes Template** aufgenommen (7 Templates total). + Überlappung mit „Alltag" (todo/calendar/notes) ist gewollt und wird + dedupliziert — „Arbeit" ergänzt Mail/Chat/Times, „Alltag" ergänzt + Contacts. +- **Kein Tier-Gating im Onboarding.** Templates zeigen alle Module + unabhängig von `requiredTier`. Wenn Production-Tiers reaktiviert + werden, greift das normale `AuthGate` beim Öffnen — das Modul-Tile + zeigt sich, nur das Öffnen führt ggf. zu Upgrade-Prompt. Bewusste + Entscheidung, Discovery nicht zu gaten. +- **Max-Module-Cap bleibt bei 8** (2×4 Grid). Bei 7 gewählten + Templates mit Dedup landen ~25 Unique-Module im Pool — die ersten + 8 nach Template-Prioritäts-Reihenfolge gewinnen, Rest über „App + hinzufügen" erreichbar. + +## Still open + +1. **Welcome-Page behalten oder löschen?** Als Marketing-Landing für + Logged-out-Pre-Signup könnte sie bleiben. Entscheidung in M5. +2. **Mobile (memoro)?** Onboarding ist v1 web-only; memoro könnte + `onboardingCompletedAt` + Templates künftig mit-konsumieren. Nicht + blockierend. +3. **Default-Mode Hell/Dunkel/System?** Aktueller Default ist unklar — + muss ich mit dem aktuellen Verhalten von `createThemeStore` + abgleichen, bevor Screen 2 fest verdrahtet ist. Wird in M3 geklärt + (ein-Zeiler-Lookup). diff --git a/services/mana-auth/src/db/schema/auth.ts b/services/mana-auth/src/db/schema/auth.ts index 2fbdc172c..131ee5db2 100644 --- a/services/mana-auth/src/db/schema/auth.ts +++ b/services/mana-auth/src/db/schema/auth.ts @@ -49,6 +49,10 @@ export const users = authSchema.table('users', { kind: userKindEnum('kind').default('human').notNull(), twoFactorEnabled: boolean('two_factor_enabled').default(false), deletedAt: timestamp('deleted_at', { withTimezone: true }), + // Null = user hasn't finished the 3-screen onboarding flow yet (Name + // → Look → Templates). The flow is skippable, but even a skip sets + // this timestamp so we don't re-prompt. See docs/plans/onboarding-flow.md. + onboardingCompletedAt: timestamp('onboarding_completed_at', { withTimezone: true }), }); // Sessions table (Better Auth schema) diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 472e02a89..cdcbfb9ee 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -24,6 +24,7 @@ import { createAuthRoutes } from './routes/auth'; import { createGuildRoutes } from './routes/guilds'; import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys'; import { createMeRoutes } from './routes/me'; +import { createOnboardingRoutes } from './routes/onboarding'; import { createEncryptionVaultRoutes } from './routes/encryption-vault'; import { createAiMissionGrantRoutes } from './routes/ai-mission-grant'; import { createSettingsRoutes } from './routes/settings'; @@ -111,6 +112,11 @@ app.route('/api/v1/me/encryption-vault', createEncryptionVaultRoutes(encryptionV // middleware above. See docs/plans/ai-mission-key-grant.md. app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrantService)); +// ─── Onboarding ──────────────────────────────────────────── +// Per-user "did you finish the 3-screen onboarding flow yet" state. +// See docs/plans/onboarding-flow.md. +app.route('/api/v1/me/onboarding', createOnboardingRoutes(db)); + // ─── Settings ────────────────────────────────────────────── app.use('/api/v1/settings/*', jwtAuth(config.baseUrl)); diff --git a/services/mana-auth/src/routes/onboarding.ts b/services/mana-auth/src/routes/onboarding.ts new file mode 100644 index 000000000..2625e2d76 --- /dev/null +++ b/services/mana-auth/src/routes/onboarding.ts @@ -0,0 +1,69 @@ +/** + * Onboarding routes — per-user completion status for the 3-screen + * first-login flow (Name → Look → Templates). + * + * GET / — { completedAt: ISO string | null } + * POST /complete — idempotent; sets `onboardingCompletedAt = now()` if null + * PATCH /reset — sets back to null (for "Onboarding erneut durchlaufen") + * + * Mounted under `/api/v1/me/onboarding`, so it inherits the same + * `jwtAuth` middleware as the GDPR `/me/*` routes. + * + * Design notes — see docs/plans/onboarding-flow.md §"Data changes": + * we keep the state on a first-class column (not in `user_settings` + * JSONB) so a brand-new account reliably returns `null` without having + * to distinguish "no settings row" from "explicitly null". And we use + * a dedicated endpoint rather than a JWT claim so finishing the flow + * takes effect without a token re-mint. + */ + +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { Database } from '../db/connection'; +import { users } from '../db/schema/auth'; + +type OnboardingApp = Hono<{ Variables: { user: AuthUser } }>; + +export function createOnboardingRoutes(db: Database) { + const app: OnboardingApp = new Hono(); + + app.get('/', async (c) => { + const user = c.get('user'); + const [row] = await db + .select({ completedAt: users.onboardingCompletedAt }) + .from(users) + .where(eq(users.id, user.userId)) + .limit(1); + + if (!row) return c.json({ error: 'User not found' }, 404); + return c.json({ completedAt: row.completedAt?.toISOString() ?? null }); + }); + + app.post('/complete', async (c) => { + const user = c.get('user'); + const now = new Date(); + const [updated] = await db + .update(users) + .set({ onboardingCompletedAt: now, updatedAt: now }) + .where(eq(users.id, user.userId)) + .returning({ completedAt: users.onboardingCompletedAt }); + + if (!updated) return c.json({ error: 'User not found' }, 404); + return c.json({ completedAt: updated.completedAt?.toISOString() ?? null }); + }); + + app.patch('/reset', async (c) => { + const user = c.get('user'); + const [updated] = await db + .update(users) + .set({ onboardingCompletedAt: null, updatedAt: new Date() }) + .where(eq(users.id, user.userId)) + .returning({ completedAt: users.onboardingCompletedAt }); + + if (!updated) return c.json({ error: 'User not found' }, 404); + return c.json({ completedAt: null }); + }); + + return app; +}