# Mobile Topics & Interests — Integration Guide

> For mobile development. How to wire **interest pickers** everywhere they
> appear: the **onboarding interests** step (fine-grained topics) and the
> **create screens** — post / activity / achievement (the 9 categories). Plus
> **search** for when the user can't find what they want.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <token>` — an `accessToken` (logged-in user) **or**
  an `onboardingToken` (during onboarding). Both work on the topic endpoints.
- Send `X-Client-Type: mobile` on every request.

Architecture / server internals: [../../topic-category-matching.md](../../topic-category-matching.md).

## What's new

- **2026-09-07** — Create screens (post / activity / achievement) now pick from the **9 categories**, not the ~1130 fine-grained topics: same field and shape, you still submit `topicSlugs` (max 2), the values are just category slugs now (`"sports-fitness"`). `GET /api/users/topics` and `?q=` return those 9; **onboarding is unchanged** and still picks fine-grained topics via `?representative=1`. Stop calling `POST /api/users/topics/suggest-categories` — its keyword matching returned unrelated topics, so it's out of the flow until semantic matching lands. When fine-grained topics come back to the create screens, only the option list changes, not the request or the response.

---

## Short version — the calls

```
GET  /api/users/topics                      → create-screen picker list (the 9 categories)
GET  /api/users/topics?q=<term>             → search that same list (≤50 results)
GET  /api/users/topics?representative=1     → onboarding interest list (~86 curated topics)
```

Create screens can skip the first call entirely — `GET …/form-schema` already
embeds the same 9 options inline.

Then the chosen topic **slugs** are submitted on the create call — **same field,
same shape everywhere**:

```
POST /api/users/me/activities      body.topicSlugs: string[]   (0–2 topic slugs)
POST /api/users/me/achievements    body.topicSlugs: string[]   (0–2 topic slugs)
POST /api/posts                    body.topicSlugs: string[]   (1–2 topic slugs)
```

The user picks **up to 2** on every create screen. Activities/achievements are
optional (0 allowed); a post needs at least 1. Today those slugs are the 9
**categories**; the field and the limit are what's stable, not the list.

---

## Concepts (1 minute)

| Thing | What it is | Example |
| --- | --- | --- |
| **Category** | One of the 9 coarse interests. Has its own `slug` — **this is what create screens pick today.** | `{ slug: "arts-creativity", name: "Arts & Creativity", category: "Arts & Creativity" }` |
| **Topic** | Fine-grained interest (~1130). Onboarding picks these. | `{ slug: "hip-hop", name: "hip hop", category: "Arts & Creativity", icon: null }` |
| **Representative** | Small curated per-category subset (~86 total) — the onboarding list. | `"football"`, `"mathematics"`, `"gaming"` |

Both are `Topic` rows on the wire, so they render and submit identically — a
category is just the one whose `name` equals its own `category`.

**One pick model everywhere:** the user selects **up to 2** by `slug` and submits
them as **`topicSlugs`** — posts, activities and achievements alike. The server
writes the picks as join rows, bumps interest weights, and **derives the category
labels** itself (you never send labels).

| Surface | picks from | `topicSlugs` count |
| --- | --- | --- |
| Activity / Achievement | the 9 categories | 0–2 (optional) |
| Post | the 9 categories | 1–2 (at least one) |
| Onboarding interests | ~86 representative topics (`interestSlugs`) | ≥3 |

---

## A. Onboarding — interest selection

No title exists yet, so **no keyword matching** — just show the curated
representative set and let the user multi-select.

**Request**

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

**Response** (~86 topics, all categories):

```json
{
  "success": true,
  "data": [
    { "id": "…", "slug": "mathematics", "name": "mathematics",
      "category": "Academics & Learning", "description": null, "icon": null, "order": 1 },
    { "id": "…", "slug": "football", "name": "football",
      "category": "Sports & Fitness", "description": null, "icon": null, "order": 0 }
  ]
}
```

**Render:** group by `category` (9 groups), show each group's topics as selectable
chips. **Submit:** collect the chosen `slug`s and PATCH them onto the onboarding
session as `data.interestSlugs` (see the onboarding guide — arrays are replaced
in full):

```
PATCH /api/onboarding/session
{ "data": { "interestSlugs": ["mathematics", "football", "web-development"] } }
```

At `/complete`, these become the user's `UserTopic` rows (`source="onboarding"`).
The server *also* records each pick's **category** as a second `UserTopic` row
(`source="onboarding:category"`) so those interests still match content tagged at
category level — nothing for you to send, and it isn't in the response.

Need something not in the ~86? See the note in section C — `?q=` searches the
create-screen list, not the full taxonomy.

> Cached 1h — safe to fetch once and reuse. Use ETag / If-None-Match.

---

## B. Create screens — the 9 categories

No suggestion call. `GET …/form-schema` (activity / achievement) already embeds
the full option list inline; for posts, fetch it once from `GET /api/users/topics`
and cache it (1h `Cache-Control`).

**Response** — 9 rows, same topic shape as everything else:

```json
{
  "success": true,
  "data": [
    { "id": "…", "slug": "academics-learning", "name": "Academics & Learning",
      "category": "Academics & Learning", "description": null, "icon": null, "order": 0 },
    { "id": "…", "slug": "sports-fitness", "name": "Sports & Fitness",
      "category": "Sports & Fitness", "description": null, "icon": null, "order": 7 }
  ]
}
```

**Render:** chips, `name` as the label. **Submit:** the chosen `slug`s as
`topicSlugs` (≤2).

```jsonc
POST /api/posts                 { "topicSlugs": ["sports-fitness"] }        // 1–2
POST /api/users/me/activities   { "topicSlugs": ["technology-innovation"] } // 0–2
POST /api/users/me/achievements { "topicSlugs": [] }                        // 0–2
```

### What the server does with them

- Writes them as the entry's / post's `topics[]` (`source="USER_SELECTED"`).
- Derives the category labels into `topicCategories[]` on activities and
  achievements — **response-only**.
- Bumps the user's interest weights for what they picked.
- Sends an **unknown slug** to the floor: dropped silently, not a `400`. A stale
  cached picker degrades instead of blocking the create.

### If the user picks nothing

Allowed on activities and achievements. If such an entry is also shared to the
feed (`shareToFeed: true`), the server may auto-tag **the shared post** with at
most **2** categories inferred from its text. The entry itself stays untagged,
and no fine-grained topic is ever auto-attached.

### Why suggestions are gone

`POST /api/users/topics/suggest-categories` still responds, but it is **out of
the flow** — don't build against it. It matched titles character-by-character
against ~1130 topic names, which produced confidently wrong suggestions. A
suggestion step returns with semantic matching; the request contract
(`topicSlugs`, max 2) won't change when it does.

---

## C. Search — "I can't find my topic"

`?q=` searches **the same list the route already returns** — the 9 categories on
create screens. With a list that short a search box is optional; it exists so the
picker keeps working unchanged when fine-grained topics come back.

**Request**

```
GET /api/users/topics?q=sport
Authorization: Bearer <token>
X-Client-Type: mobile
```

**Response** — same topic shape, capped at **50**:

```json
{ "success": true, "data": [
  { "id": "…", "slug": "sports-fitness", "name": "Sports & Fitness",
    "category": "Sports & Fitness", "description": null, "icon": null, "order": 7 }
]}
```

- Case-insensitive **substring** match on name/slug.
- Not cached (`Cache-Control: no-store`) — debounce ~300 ms.
- Rate-limited (30/min per user/session) — the debounce keeps you well under.
- Use the returned `slug` as `topicSlugs` on any create screen.
- **Onboarding is not covered by search.** `?representative=1` is its own list;
  search does not reach the wider ~1130-topic taxonomy while create screens are
  category-level.

---

## Recommended picker UX (all surfaces)

1. **Create screens** — 9 category chips, straight from `field.options` /
   `GET /api/users/topics`. No title-dependent step, nothing to debounce.
2. **Onboarding** — `?representative=1`, grouped by `category` into 9 sections.
3. Selection state is just a set of `slug`s — ≤2 on create screens, ≥3 in
   onboarding.

---

## Gotchas

- **Slugs are the wire format.** Submit `slug` / `value` (`"sports-fitness"`),
  never the display label (`"Sports & Fitness"`). Sending labels is the single
  most common integration bug here — they're silently dropped, and the entry
  saves with no interests.
- **Always `topicSlugs`, max 2.** Posts, activities, achievements all submit the
  same field. `topicCategories` is **response-only** — sending it does nothing.
- **Create screens and onboarding use different lists.** 9 categories vs ~86
  representative topics. Don't share one cached list between them.
- **Unknown slugs are dropped, not rejected.** A create with only unknown slugs
  succeeds with zero topics — validate against the option list client-side.
- **This list will get finer later.** Build the picker off `field.options` /
  `GET /api/users/topics` rather than a hardcoded 9, and the switch costs you
  nothing.
