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:
Till JS 2026-04-23 22:24:49 +02:00
parent bdd4e05446
commit 5a92e1168b
5 changed files with 504 additions and 0 deletions

View 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();

View 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 (140 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)
- M1M4 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).

View file

@ -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)

View file

@ -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));

View 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;
}