mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(onboarding): M1 — data model + endpoints + client store
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
bdd4e05446
commit
5a92e1168b
5 changed files with 504 additions and 0 deletions
105
apps/mana/apps/web/src/lib/stores/onboarding-status.svelte.ts
Normal file
105
apps/mana/apps/web/src/lib/stores/onboarding-status.svelte.ts
Normal file
|
|
@ -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<Date | null>(null);
|
||||
let loaded = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
async function authedFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
320
docs/plans/onboarding-flow.md
Normal file
320
docs/plans/onboarding-flow.md
Normal file
|
|
@ -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:<userId>` 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).
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
69
services/mana-auth/src/routes/onboarding.ts
Normal file
69
services/mana-auth/src/routes/onboarding.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue