# Mobile Onboarding — Integration Guide

> For the mobile developer. Read top-to-bottom. Every section is a step you'll
> implement. No backend changes needed on your side.

This document explains how to drive the new multi-step registration flow from
the mobile app. Everything you need is described here.

- API base URL (local): `http://localhost:3001`
- All requests are JSON. Send `Content-Type: application/json`.
- Send the header `X-Client-Type: mobile` on **every** request so the API
  treats you as a mobile client (skips refresh-token cookies, returns tokens
  in JSON bodies).

## What's new

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **2026-09-07** — The interests screen must now call `GET /api/users/topics?representative=1` (~86 curated topics). Plain `GET /api/users/topics` no longer returns the full taxonomy — it now returns the 9 interest **categories**, which is what the post / activity / achievement create screens pick from. Everything else about the step is unchanged: still `data.interestSlugs[]`, still at least 3 slugs.

---

## Short version — just the API calls

The whole integration is six endpoints. Everything else is detail.

```
0. GET    /api/users/topics?representative=1  → interest catalog (~86 curated topics)
   GET    /api/intents                    → intent catalog
1. POST   /api/onboarding/session         → onboardingToken
2. PATCH  /api/onboarding/session         (× N — one per checkpoint)
3. POST   /api/onboarding/media-presign   → { uploadUrl, publicUrl }   (per file)
   PUT    <uploadUrl>                     (file bytes, direct to S3)
4. POST   /api/onboarding/complete        → { user, accessToken }
5. POST   /api/auth/login                 → accessToken + refreshToken
```

> The two catalog endpoints (0) accept **either** an `accessToken` (logged-in user)
> **or** the `onboardingToken` returned by step 1. Slugs are stable — feel free to
> cache responses locally for the session (server sends `Cache-Control: max-age=3600`).

`media-presign` accepts `kind: "avatar" | "activity" | "achievement"`. Same
endpoint for every onboarding upload — avatar, activity photos, achievement
photos. **Images only** (no video).

Auth headers:

- **1, 5** — no auth.
- **0, 2, 3, 4** — `Authorization: Bearer <onboardingToken>` (returned by 1).
  The catalog endpoints in (0) also accept a real `accessToken` if you have one.
- **PUT to S3** — no auth headers; the signed URL is the credential.
- Send `X-Client-Type: mobile` on every API call except the S3 PUT.

Minimum bodies:

```jsonc
// 1. Create session — only `password` + one identifier are required.
{
  "email": "kid@example.com",                 // OR "phoneNumber"
  "password": "MyStr0ngPass",
  "data": {                                   // optional pre-fill
    "basic": { "dateOfBirth": "2012-04-23", "gender": "FEMALE" },
    "name":  { "fullName": "Ada Lovelace" }
  }
}

// 2. Checkpoint sync — partial deep-merge. Send only what changed.
//    Arrays REPLACE, objects merge. Skip a screen ⇒ omit its key.
{ "data": { "interestSlugs": ["sports","music"] }, "checkpoint": "interests" }

// 3. Media presign — works for avatar, activity, achievement uploads.
{ "kind": "avatar", "filename": "avatar.jpg", "contentType": "image/jpeg" }
// then PUT bytes to uploadUrl with that exact Content-Type, then PATCH:
// - kind=avatar      → data.profile.avatarUrl = publicUrl
// - kind=activity    → push into data.activities[i].media[]
// - kind=achievement → push into data.achievements[i].media[]

// 4. Complete — only `data.basic.dateOfBirth` is required; everything else
//    optional. Username auto-derives if absent.
{}
```

Rules of thumb:

- Hold all screen state locally. Only PATCH at checkpoint boundaries — not
  on every keystroke.
- `data.basic.dateOfBirth` is the only field `/complete` _requires_ (plus an
  email/phone, already on the session).
- `/complete` is idempotent — retry on network blips. A 200 with
  `alreadyCompleted: true` means it already ran; just store the token.
- 401 on a PATCH ⇒ token expired ⇒ wipe local state, start over from #1.
- OTP is parked. `code` is optional today. Don't send it.

Full walkthrough, schemas, error tables, and curl examples in the sections
below.

---

## TL;DR — wireframe screens mapped to API calls

Each wireframe screen below corresponds to one of three things: local-only
(no backend hit), a `POST /api/onboarding/session` (only once — first server
call), or a `PATCH /api/onboarding/session` (every subsequent checkpoint).

```
┌────────────────────────────────────────────────────────────────────────────┐
│ Screen 1  DOB + Gender                                                     │
│           → LOCAL ONLY. Hold values in app state.                          │
├────────────────────────────────────────────────────────────────────────────┤
│ Screen 2  First name + Last name + Email + Password                        │
│           → POST /api/onboarding/session                                   │
│             Body bundles Screen 1 + Screen 2 inside `data`.                │
│             Returns `onboardingToken` (Bearer JWT, 30-day TTL).            │
├────────────────────────────────────────────────────────────────────────────┤
│ Screen 3  School / education                                               │
│           → PATCH /api/onboarding/session  checkpoint: "education"         │
│ Screen 4  Interests (topic slugs)                                          │
│           → PATCH /api/onboarding/session  checkpoint: "interests"         │
│ Screen 5  Intent (why join)                                                │
│           → PATCH /api/onboarding/session  checkpoint: "intent"            │
│ Screen 6  Avatar + Bio                                                     │
│           → POST /api/onboarding/media-presign  kind: "avatar"             │
│           → PUT  <uploadUrl>                    (binary, direct to S3)     │
│           → PATCH /api/onboarding/session       checkpoint: "profile"      │
│ Screen 7  Activities (each with photos)                                    │
│           For each media file:                                             │
│             POST /api/onboarding/media-presign  kind: "activity"           │
│             PUT  <uploadUrl>                                               │
│           → PATCH /api/onboarding/session  checkpoint: "activities"        │
│ Screen 8  Achievements (each with photos)                                  │
│           For each media file:                                             │
│             POST /api/onboarding/media-presign  kind: "achievement"        │
│             PUT  <uploadUrl>                                               │
│           → PATCH /api/onboarding/session  checkpoint: "achievements"      │
├────────────────────────────────────────────────────────────────────────────┤
│ Final     Review screen → POST /api/onboarding/complete                    │
│           → real User + accessToken returned.                              │
│           Follow up: POST /api/auth/login → refreshToken.                  │
└────────────────────────────────────────────────────────────────────────────┘
```

Rule of thumb: **only Screen 2 and the avatar upload go to the network mid-flow.**
Everything else either stays local or PATCHes the session bag at a checkpoint
boundary. Screen order is a guideline — you can reorder or add screens later
without backend changes; just send the right keys in `data`.

> **Heads up — OTP / contact verification is parked for now.** While the
> mobile flow is being built we are NOT sending or requiring a verification
> code. The endpoints (`POST /api/verification/send` + the `code` field on
> `/onboarding/session`) still exist — they're just optional. We'll turn this
> back on once the end-to-end flow is stable. See the "OTP is currently
> optional" callout below for the full picture.

---

## 1. Get TypeScript types from our OpenAPI spec

The API publishes its OpenAPI 3 spec at:

```
http://localhost:3001/api/docs/json
```

Use `openapi-typescript` (or `orval` / `kubb`) to generate types on the
mobile side. Example with `openapi-typescript`:

```bash
npx openapi-typescript http://localhost:3001/api/docs/json -o src/api/schema.ts
```

Re-run when the backend ships a new API version (I'll tell you). You can
also browse `http://localhost:3001/api/docs` for the interactive Swagger UI.

If you need runtime validation on the mobile side (form validation that
mirrors the server's Zod rules), we can publish `@ksn/validators`
to a private registry. For now, types are enough.

---

## 2. Authentication during onboarding

Two tokens are involved:

| Token               | When you get it                          | When you use it                                                   | Header                                    |
| ------------------- | ---------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------- |
| **onboardingToken** | `POST /api/onboarding/session` response  | All subsequent onboarding calls (GET / PATCH / complete / DELETE) | `Authorization: Bearer <onboardingToken>` |
| **accessToken**     | `POST /api/onboarding/complete` response | All authenticated API calls after registration finishes           | `Authorization: Bearer <accessToken>`     |

Keep the `onboardingToken` in secure storage (Keychain on iOS / Keystore on
Android). It's valid for **30 days** — long enough for the user to abandon
the flow on Day 1 and come back on Day 5.

---

## 2b. Interest + Intent catalogs

Don't hardcode the lists on the client — fetch them from the API. Both
endpoints are dual-auth: send the `onboardingToken` from step 1 (preferred
during signup) **or** an `accessToken` if the user is already logged in.

Per-route rate-limit: 30 req/min per token. Responses are
`Cache-Control: public, max-age=3600` — cache for the session, refetch on
cold start.

### Interests — `GET /api/users/topics?representative=1`

Returns the curated interest set (~86 topics across the 9 categories). Group on
the client by the `category` field to render section headers (the wireframe shows
two-level lists like "Technology & Innovation → Robotics / AI & ML / …"). Persist
`slug` values in your local state; that's what the server expects when you PATCH
`data.interestSlugs[]`.

> **`?representative=1` is required here.** Without it the route returns the 9
> categories — the list the *create* screens pick from, not this one. The server
> records the category behind each pick for you, so this step stays fine-grained.

```http
GET /api/users/topics?representative=1
Authorization: Bearer <onboardingToken | accessToken>
X-Client-Type: mobile
```

Response (truncated):

```json
{
  "success": true,
  "data": [
    {
      "id": "...",
      "slug": "stem",
      "name": "STEM",
      "category": "Academics & Learning",
      "description": "Science, technology, engineering, math",
      "icon": "beaker",
      "order": 0
    },
    {
      "id": "...",
      "slug": "ai-machine-learning",
      "name": "AI & Machine Learning",
      "category": "Technology & Innovation",
      "icon": "sparkles",
      "order": 11
    }
    // ... ~38 entries
  ]
}
```

Categories currently shipping (in order):

1. Academics & Learning
2. Technology & Innovation
3. Business & Leadership
4. Arts & Creativity
5. Sports & Fitness
6. Competitive Activities
7. Gaming & Digital Culture
8. Lifestyle

### Intents — `GET /api/intents`

```http
GET /api/intents
Authorization: Bearer <onboardingToken | accessToken>
X-Client-Type: mobile
```

Response:

```json
{
  "success": true,
  "data": [
    {
      "slug": "competitions",
      "name": "Competitions",
      "description": "Looking for competitive events & challenges",
      "icon": "trophy",
      "order": 1
    },
    {
      "slug": "events",
      "name": "Events",
      "description": "Want to attend workshops & social gatherings",
      "icon": "calendar",
      "order": 2
    },
    {
      "slug": "find-teammates",
      "name": "Find Teammates",
      "description": "Searching for people to collaborate with",
      "icon": "users",
      "order": 3
    },
    {
      "slug": "communities",
      "name": "Communities",
      "description": "Interested in joining interest-based groups",
      "icon": "users-round",
      "order": 4
    },
    {
      "slug": "volunteering",
      "name": "Volunteering",
      "description": "Want to make a positive impact",
      "icon": "heart-handshake",
      "order": 5
    },
    {
      "slug": "exploring",
      "name": "Exploring",
      "description": "Just checking out opportunities",
      "icon": "compass",
      "order": 6
    }
  ]
}
```

### Sending the user's choices

Both selections sync via PATCH at their respective checkpoints — slug
arrays only. At `/complete` the server resolves interest slugs to `Topic`
rows (writes `UserTopic`) and maps intent slugs to the `IntentKind` enum
stored as an array on `User.intents`:

```http
PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{ "data": { "interestSlugs": ["ai-machine-learning","robotics","music"] }, "checkpoint": "interests" }
```

```http
PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{ "data": { "intentSlugs": ["competitions","find-teammates"] }, "checkpoint": "intent" }
```

Unknown slugs are silently dropped at `/complete`. Validate against the
fetched catalog before submit.

---

## 3. The flow, step by step

### Screen 1 — DOB + Gender (local only)

Collect date of birth and gender. **No network call here.** Hold the values
in local state — they'll be sent as `data.basic` on the very next request.

Why: the session is created at Screen 2 (it needs the password). Saving a
round-trip and keeping Screen 1 abort-friendly.

### Screen 2 — Name + Email + Password → create the session

This is the first server call. Bundle Screen 1 + Screen 2 values into the
request body. The server creates an `OnboardingSession`, hashes the password,
and returns an `onboardingToken` used for everything that follows.

> **OTP is currently optional.** Skip the `code` field. We'll wire OTP back
> on once the rest of the flow is stable. See "When we re-enable OTP" below
> if you're curious about the eventual shape.

Validate password locally first (server enforces the same rules):

- min 8 chars
- at least one uppercase
- at least one lowercase
- at least one digit

Then hit:

```http
POST /api/onboarding/session
Content-Type: application/json
X-Client-Type: mobile

{
  "email": "kid@example.com",            // OR "phoneNumber": "+15551234567"
  "password": "MyStr0ngPass",
  "parentEmail": "parent@example.com",   // optional — only if minor
  "data": {
    "basic": {
      "dateOfBirth": "2012-04-23",       // from Screen 1
      "gender": "FEMALE"                 // from Screen 1
    },
    "name": {
      "fullName": "Ada Lovelace"         // from Screen 2 (single Full Name input)
      // — or send firstName/lastName separately if you collect them as two fields.
    }
  },
  "checkpoint": "account-created"        // optional analytics label
}
```

Response:

```json
{
  "success": true,
  "data": {
    "onboardingToken": "eyJhbGciOi...",
    "session": {
      "id": "...",
      "data": { "basic": { "dateOfBirth": "2012-04-23", "gender": "FEMALE" } },
      "reachedCheckpoints": ["identity"],
      "lastCheckpoint": "identity",
      "expiresAt": "2026-06-11T...",
      "createdAt": "2026-05-12T..."
    }
  }
}
```

Store `onboardingToken` securely. Use it on every subsequent onboarding call.

#### Errors you'll see

| Status | Code                  | Meaning                                       | What to do                                                 |
| ------ | --------------------- | --------------------------------------------- | ---------------------------------------------------------- |
| 400    | `INVALID_OTP`         | Wrong code (only when OTP is enabled)         | Let user retry. After max attempts, restart from Screen 2. |
| 409    | `ACCOUNT_EXISTS`      | Email/phone already belongs to a real account | Redirect to login screen.                                  |
| 429    | `RATE_LIMIT_EXCEEDED` | Too many sends in 15 min                      | Show "try later" message.                                  |

#### When we re-enable OTP (later)

Two extra steps slot in _before_ the `POST /api/onboarding/session` call at
Screen 2:

- **Send OTP** (right after the user enters their email/phone on Screen 2)

  ```http
  POST /api/verification/send
  Content-Type: application/json
  X-Client-Type: mobile

  {
    "channel": "email",                // or "sms"
    "identifier": "kid@example.com",   // email or E.164 phone number
    "purpose": "email_verification"    // or "phone_verification"
  }
  ```

  Response: `{ "success": true, "data": { "expiresIn": 300 } }`

  6-digit code, valid 5 min, 3-attempt cap, rate-limited at 5 sends per 15 min.

- **Collect OTP locally** and include it as the `code` field in the
  `POST /api/onboarding/session` body. The route will confirm and consume
  the code inline. We'll flip `code` from optional → required when this
  turns back on.

---

### Screens 3+ — Collect everything else, sync at checkpoints

After Screen 2 you have a session. The user now moves through the remaining
wireframe screens at their own pace. **Hold this state in local app storage**
(e.g., `AsyncStorage`, MMKV, or Zustand persist). Don't hit the backend per
screen.

A reasonable shape to mirror server expectations:

```ts
type OnboardingData = {
  basic?: { dateOfBirth?: string; gender?: Gender };
  name?: {
    fullName?: string; // wireframe's single Full Name input — server splits at /complete
    firstName?: string; // optional; wins over fullName split if sent
    lastName?: string; // optional; wins over fullName split if sent
    username?: string; // optional; auto-derived at /complete if absent
  };
  profile?: { displayName?: string; bio?: string; avatarUrl?: string };
  education?: EducationItem[];
  activities?: ActivityItem[];
  achievements?: AchievementItem[];
  interestSlugs?: string[]; // ["ai-machine-learning", "music", ...] — from /api/users/topics?representative=1
  intentSlugs?: string[]; // ["competitions", "find-teammates", ...] — fetched from /api/intents
};

type Gender = "MALE" | "FEMALE" | "NON_BINARY" | "OTHER" | "PREFER_NOT_TO_SAY";

type EducationItem = {
  schoolName: string;
  level?: "elementary" | "middle" | "high" | "college" | "other";
  grade?: string; // "5th", "10th"
  startYear?: number;
  endYear?: number;
  isCurrent?: boolean;
  city?: string;
  country?: string;
  order?: number; // for user-controlled sort
};

type InlineMediaItem = {
  type: "image"; // image only — video/gif not accepted
  url: string; // returned by /media-presign as publicUrl
  thumbnailUrl?: string;
  width?: number;
  height?: number;
  size?: number;
  altText?: string;
};

type ActivityItem = {
  title: string; // wireframe: "Activity name"
  type?: string; // activity type — option value, or the sentinel "other"
  typeOther?: string; // free-text "Activity Description" — send when type === "other"
  role?: string; // wireframe: "Role"
  description?: string;
  topicSlugs?: string[];      // 0–2 category slugs → the entry's topics + feed weights (see below)
  // ---- legacy / optional (kept for back-compat; not in the current design) ----
  area?: string;
  organisation?: string;
  duration?: string;
  category?: string;
  startDate?: string;
  endDate?: string;
  isOngoing?: boolean;
  location?: string;
  media?: InlineMediaItem[]; // photos for this activity (images only)
  order?: number;
};

type AchievementItem = {
  title: string; // wireframe: "Award/Competition name"
  level?: string; // "school" | "regional" | "district" | "national" | "international"
  awardedAt?: string; // ISO date — wireframe: "Date"
  description?: string;
  topicSlugs?: string[];      // 0–2 category slugs → the entry's topics + feed weights (see below)
  // ---- legacy / optional ----
  area?: string;
  issuer?: string;
  category?: string;
  media?: InlineMediaItem[]; // photos for this achievement (images only)
  order?: number;
};
```

---

### Sync at checkpoints (NOT every screen)

When the user finishes a wireframe screen, send the bag with a checkpoint
label.

```http
PATCH /api/onboarding/session
Content-Type: application/json
Authorization: Bearer <onboardingToken>
X-Client-Type: mobile

{
  "data": {
    "name": { "username": "ada_l" }
  },
  "checkpoint": "username"
}
```

#### Checkpoint names aligned to the wireframe screens

You pick the names. Server logs them for drop-off analytics. The wireframe-
aligned set:

| Wireframe screen                     | Checkpoint name   | What's in `data`                                                          |
| ------------------------------------ | ----------------- | ------------------------------------------------------------------------- |
| Screen 2 (server bootstrap)          | `account-created` | `basic`, `name.fullName` (or `name.firstName/lastName`)                   |
| Username picker (optional, any time) | `username`        | `name.username` — if omitted, server auto-derives at `/complete`          |
| Screen 3 — School                    | `education`       | `education[]`                                                             |
| Screen 4 — Interests                 | `interests`       | `interestSlugs[]`                                                         |
| Screen 5 — Intent                    | `intent`          | `intentSlugs[]`                                                           |
| Screen 6 — Avatar + Bio              | `profile`         | `profile.avatarUrl`, `profile.bio` (and optionally `profile.displayName`) |
| Screen 7 — Activities                | `activities`      | `activities[]`                                                            |
| Screen 8 — Achievements              | `achievements`    | `achievements[]`                                                          |

**Important rules:**

- **Arrays replace, objects merge.** If you send `data.education`, the entire
  list overwrites the stored one. So send the full list every time. For
  objects like `data.profile`, you can send just the keys you changed —
  the server merges per-key.
- **Don't sync after every keystroke.** Send when the user taps "Continue"
  on a checkpoint screen.
- **Use `undefined` to skip, `null` to clear.** Omitting a key leaves the
  stored value alone. Sending `null` for a single object key clears it.
- **Idempotent.** If a PATCH fails (network), retry safely with the same
  payload.

#### Checking username availability live

Before the user submits a username, do a soft check:

```http
GET /api/onboarding/username-available?u=ada_l
```

```json
{ "success": true, "data": { "username": "ada_l", "available": true } }
```

This is a **soft** check — there's a race window. The final `complete` call
does a hard check.

#### Resuming after app close

On next launch, if you still have an `onboardingToken` and the user hasn't
completed onboarding, re-hydrate from the server:

```http
GET /api/onboarding/session
Authorization: Bearer <onboardingToken>
```

Response gives you the full `data` bag back plus `reachedCheckpoints`. Use
it as the source of truth — overwrite your local cache.

If the response is `404` or `410` (expired), clear local state and start
from Screen 1.

---

### Media uploads (avatar, activity, achievement) — one endpoint

Every onboarding upload goes through `POST /api/onboarding/media-presign`.
The body's `kind` field decides what's allowed and where the resulting
`publicUrl` lands in the session:

**Image-only** across every onboarding kind — `image/jpeg`, `image/png`,
`image/webp` (max **10 MB**), plus `image/heic`/`image/heif` iOS photos (max
**15 MB**). Video is not accepted (`415`). The request also requires `size`
(exact bytes), which the server caps + pins as `Content-Length`.

| kind          | Allowed content types                     | Where the URL lives in `data`                    |
| ------------- | ----------------------------------------- | ------------------------------------------------ |
| `avatar`      | `image/jpeg`, `image/png`, `image/webp`   | `data.profile.avatarUrl` (string)                |
| `activity`    | `image/jpeg`, `image/png`, `image/webp`   | append to `data.activities[i].media[]` (array)   |
| `achievement` | `image/jpeg`, `image/png`, `image/webp`   | append to `data.achievements[i].media[]` (array) |

> The general media flow (`/api/media/presign`) requires a real `accessToken`
> and won't work mid-onboarding. This route accepts the `onboardingToken`
> and stores objects under `onboarding/<sessionId>/<kind>/...` so the
> cleanup sweep can hard-delete all artifacts of an abandoned session.

#### Step A — Ask the API for a signed upload URL

```http
POST /api/onboarding/media-presign
Content-Type: application/json
Authorization: Bearer <onboardingToken>
X-Client-Type: mobile

{
  "kind": "activity",                  // "avatar" | "activity" | "achievement"
  "filename": "trophy.jpg",
  "contentType": "image/jpeg",
  "size": 482910
}
```

Response:

```json
{
  "success": true,
  "data": {
    "kind": "activity",
    "uploadUrl": "https://minio.local/ksn-media/onboarding/<sessionId>/activity/...jpg?X-Amz-Signature=...",
    "publicUrl": "https://minio.local/ksn-media/onboarding/<sessionId>/activity/...jpg",
    "key": "onboarding/<sessionId>/activity/...jpg",
    "contentType": "image/jpeg"
  }
}
```

`uploadUrl` is valid for ~5 minutes. Errors: `415 UNSUPPORTED_TYPE`
(not an allowed image type — video is never allowed), `413 FILE_TOO_LARGE`
(`size` over 10 MB), `404` (session not active), `410 EXPIRED`.

#### Step B — PUT the bytes directly to storage

Send the raw file body to `uploadUrl`. **Do NOT add the `Authorization` or
`X-Client-Type` headers on this request** — it goes straight to S3/MinIO,
which has its own auth baked into the signed URL.

```http
PUT <uploadUrl>
Content-Type: image/jpeg
Content-Length: 482910

<binary file bytes>
```

Use the same `Content-Type` **and** the same `Content-Length` (= `size`) you
sent in Step A — the signature is bound to both, and storage rejects a body of
any other length. On success, S3/MinIO returns 200 with an empty body.

React Native example using `fetch`:

```ts
await fetch(uploadUrl, {
  method: "PUT",
  headers: { "Content-Type": "image/jpeg" }, // fetch sets Content-Length from the body
  body: fileBlob, // or `new Blob([bytes])`
});
```

#### Step C — Save the URL on the session

**Avatar:**

```http
PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{
  "data": { "profile": { "avatarUrl": "<publicUrl>" } },
  "checkpoint": "profile"
}
```

**Activity (Screen 7) — each activity has its own `media[]`:**

```http
PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{
  "data": {
    "activities": [
      {
        "title": "School band lead vocals",
        "role": "Lead vocalist",
        "area": "Music",
        "type": "Group / club",
        "organisation": "Lincoln High Music Club",
        "duration": "2 years",
        "startDate": "2024-04-01",
        "description": "Performed at four events.",
        "media": [
          { "type": "image", "url": "<publicUrl from media-presign>" }
        ]
      }
    ]
  },
  "checkpoint": "activities"
}
```

**Achievement (Screen 8) — each achievement has its own `media[]`:**

```http
PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{
  "data": {
    "achievements": [
      {
        "title": "Regional Math Olympiad — Gold",
        "area": "Academic",
        "level": "District",
        "issuer": "Math Educators Association",
        "awardedAt": "2025-11-22",
        "description": "First place, ages 12–14 division.",
        "media": [
          { "type": "image", "url": "<publicUrl from media-presign>" }
        ]
      }
    ]
  },
  "checkpoint": "achievements"
}
```

> Arrays REPLACE on PATCH. Send the full list of activities (or
> achievements) every time you sync this checkpoint — including their full
> `media[]` lists. If the user removes a file, drop it from the array.

**Notes**

- If the user re-picks media, just repeat Steps A→C. Orphaned objects under
  `onboarding/<sessionId>/` get GC'd by the cleanup sweep (TBD).
- On `/complete`, the server commits Activity/Achievement rows and inserts
  their media into the polymorphic `Media` table — same table used for post
  and event media. Look up via `ownerType` + `ownerId`.
- **Interests (optional):** each activity/achievement item may carry
  `topicSlugs` (0–2 **category** slugs — the same values the create screens use,
  from plain `GET /api/users/topics`). At `/complete` the server writes them as
  the entry's topics and seeds the user's feed interest weights. This is
  **separate** from the Screen-4 "Interests" step (`data.interestSlugs`, which is
  fine-grained). If you don't collect it during onboarding, omit it —
  activities/achievements still commit. Architecture:
  [topic-category-matching.md](../../topic-category-matching.md).

---

### Final review screen — Complete

When the user finishes the last screen, commit:

```http
POST /api/onboarding/complete
Content-Type: application/json
Authorization: Bearer <onboardingToken>
X-Client-Type: mobile

{
  "data": { /* optional final delta */ }
}
```

Response:

```json
{
  "success": true,
  "data": {
    "user": {
      "id": "...",
      "email": "kid@example.com",
      "username": "ada_l",
      "isMinor": true,
      "requiresParentalConsent": false,
      "createdAt": "2026-05-12T..."
    },
    "accessToken": "eyJhbGciOi..."
  }
}
```

Required fields for `/complete` to succeed:

- `data.basic.dateOfBirth` — must produce an age between 8 and 100
- An identifier — `email` OR `phoneNumber` (already on the session from
  Screen 2)

**Everything else is optional, including `username`.** If the user hasn't
picked a username by the time you hit `/complete`, the server auto-derives
one from `data.name.firstName` (or the email local-part, or `"user"` as last
resort), sanitized to `[a-z0-9_]` and probed against the DB so it doesn't
collide. The user can edit their username later from the profile screen.

If `displayName` is missing, the server falls back to `fullName`, then to
`firstName`, then to whatever username got used.

**Name handling** — the wireframe collects a single "Full name" input on
Screen 2. Send it as `data.name.fullName` and the server will split it at
`/complete`: first whitespace-delimited token → `firstName`, remainder →
`lastName`. If you collect first/last as two separate fields, send those
explicitly and they win over the `fullName` split.

Errors:

| Status | Code               | Meaning                                                                                                             |
| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------- |
| 422    | `MISSING_DOB`      | DOB wasn't set. Go back to Screen 1.                                                                                |
| 422    | `INVALID_AGE`      | DOB out of range (under 8, over 100).                                                                               |
| 409    | `UNIQUE_VIOLATION` | Username got taken in the race between availability check and `/complete`. Prompt the user to pick a different one. |
| 410    | `EXPIRED`          | Session past its 30-day TTL. Restart from Screen 1.                                                                 |

If `/complete` returns `200` (not `201`) with `alreadyCompleted: true`, the
user already completed this session — treat it the same way: store the
access token and move on. (Happens on retry after a flaky network.)

---

### After completion — Get a refresh token

`/complete` only gives you an `accessToken`. To get long-lived auth, call
the existing login route with the same credentials. This is where the device
session + refresh token get minted.

```http
POST /api/auth/login
Content-Type: application/json
X-Client-Type: mobile

{
  "identifier": "kid@example.com",     // email or username
  "password": "MyStr0ngPass"
}
```

Response includes both `accessToken` and `refreshToken`. Store them
securely. After this, the user is fully authenticated — discard
`onboardingToken`.

---

## 4. Common scenarios

### User backgrounds the app mid-onboarding

Nothing to do server-side. Local state persists; resume from the same
screen. Server state is intact for 30 days.

### User switches devices mid-onboarding

The new device has no `onboardingToken`. They'd have to start over from
Screen 1 — but the `OnboardingSession` for the same email is still ACTIVE.
The backend will mark the prior session as ABANDONED when the new one is
created. No data carries over (yet). _Future enhancement: cross-device
resume via email magic link._

### User abandons onboarding

Just stop calling the API. After 30 days, the cleanup job marks the session
ABANDONED. If you want to be explicit, call:

```http
DELETE /api/onboarding/session
Authorization: Bearer <onboardingToken>
```

### Server returns 401 on a PATCH

The `onboardingToken` is invalid or expired. Wipe local onboarding state
and restart from Screen 1.

### User wants to change email mid-flow

Not currently supported in a single session. Easiest path: `DELETE` the
session and start over with the new email.

---

## 5. Reference — every endpoint

| Method   | Path                                       | Auth                      | Body                                                                                                                                    |
| -------- | ------------------------------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `POST`   | `/api/verification/send`                   | none                      | `{ channel, identifier, purpose }` — _parked; skip for now_                                                                             |
| `POST`   | `/api/onboarding/session`                  | none                      | `{ email \| phoneNumber, password, code?, parentEmail?, data?, checkpoint? }` — `code` optional during early dev                        |
| `GET`    | `/api/onboarding/session`                  | onboardingToken           | —                                                                                                                                       |
| `PATCH`  | `/api/onboarding/session`                  | onboardingToken           | `{ data?, checkpoint? }`                                                                                                                |
| `POST`   | `/api/onboarding/media-presign`            | onboardingToken           | `{ kind: "avatar" \| "activity" \| "achievement", filename, contentType, size }` — images only (max 10 MB); returns `{ kind, uploadUrl, publicUrl, key, contentType }` |
| `POST`   | `/api/onboarding/complete`                 | onboardingToken           | `{ data? }`                                                                                                                             |
| `DELETE` | `/api/onboarding/session`                  | onboardingToken           | —                                                                                                                                       |
| `GET`    | `/api/onboarding/username-available?u=...` | none                      | —                                                                                                                                       |
| `GET`    | `/api/users/topics`                        | onboardingToken \| access | — Interest catalog (grouped by `category`). 30 req/min, 1h cacheable.                                                                   |
| `GET`    | `/api/intents`                             | onboardingToken \| access | — Intent catalog. 30 req/min, 1h cacheable.                                                                                             |
| `POST`   | `/api/auth/login`                          | none                      | `{ identifier, password }`                                                                                                              |

All responses follow the standard envelope:

```json
{ "success": true, "data": { ... } }
```

Errors:

```json
{ "success": false, "error": "Human message", "code": "MACHINE_CODE" }
```

---

## 6. Questions / changes

If you need:

- Cross-device resume (magic link)
- Authenticated media upload during onboarding
- A different OTP channel (WhatsApp, push)
- Server-side runtime validators (shared `@ksn/validators` package on the
  mobile side)
- New fields on `Education`, `Activity`, `Achievement`

… ping the backend team. Most things are additive — we can extend the
`data` Json bag without breaking anything you've already shipped.

---

## 7. Quick test with `curl`

OTP is parked, so this happy-path skips `/api/verification/send` entirely.
When OTP is turned back on, you'll add a `code` field to the session-create
call below.

```bash
# 1. Start session — no OTP, no code field
curl -X POST http://localhost:3001/api/onboarding/session \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{
    "email":"test@example.com",
    "password":"Password123",
    "data":{
      "basic":{"dateOfBirth":"2012-04-23","gender":"FEMALE"},
      "name":{"fullName":"Ada Lovelace"}
    },
    "checkpoint":"account-created"
  }'

# Save the onboardingToken from the response → $TOKEN

# 1b. Fetch interest + intent catalogs (cache locally)
curl http://localhost:3001/api/users/topics \
  -H "X-Client-Type: mobile" \
  -H "Authorization: Bearer $TOKEN"

curl http://localhost:3001/api/intents \
  -H "X-Client-Type: mobile" \
  -H "Authorization: Bearer $TOKEN"

# 2. Patch a checkpoint
curl -X PATCH http://localhost:3001/api/onboarding/session \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "data":{"name":{"username":"ada_l"}},
    "checkpoint":"username"
  }'

# 3. Get a presigned avatar upload URL
curl -X POST http://localhost:3001/api/onboarding/media-presign \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"kind":"avatar","filename":"me.jpg","contentType":"image/jpeg"}'
# → grab `uploadUrl` and `publicUrl` from response → $UPLOAD_URL / $PUBLIC_URL

# 3b. PUT the file directly to storage (no auth headers — the URL is signed)
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/jpeg" \
  --data-binary @me.jpg

# 3c. Save the public URL back on the session
curl -X PATCH http://localhost:3001/api/onboarding/session \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -H "Authorization: Bearer $TOKEN" \
  -d "{\"data\":{\"profile\":{\"avatarUrl\":\"$PUBLIC_URL\"}},\"checkpoint\":\"profile\"}"

# 4. Complete
curl -X POST http://localhost:3001/api/onboarding/complete \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{}'

# 5. Login to get refresh token
curl -X POST http://localhost:3001/api/auth/login \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"identifier":"test@example.com","password":"Password123"}'
```

### When OTP is enabled again

Two extra calls slot in before the session-create call (curl #1 above):

```bash
# 0a. Send OTP
curl -X POST http://localhost:3001/api/verification/send \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"channel":"email","identifier":"test@example.com","purpose":"email_verification"}'

# 0b. Read the 6-digit code from the API logs (dev only — console channel)
#     or from the email/SMS in real environments.

# Then the session-create call (curl #1) grows a `code` field:
#   "code":"123456",
```
