# Mobile Activities & Achievements — Integration Guide

> For mobile development. Everything needed to power the **Activity** and
> **Achievement** create / edit screens — fetch the form schema, render the
> fields, hit the dropdown endpoints, attach media, submit.

- API base URL (local): `http://localhost:3001`
- Auth: `accessToken` (logged-in user). Send as `Authorization: Bearer <token>`.
- Send `X-Client-Type: mobile` on every request.

## What's new

- **2026-09-07** — Interests are picked as categories again, but the field is `topicSlugs`, not `topicCategories`: send the category's **slug** (`"sports-fitness"`), 0–2 of them, from the inline `topics` options in form-schema. This guide previously documented `topicCategories` with raw labels — that was **wrong since 2026-07-21**, when the API stopped reading that field and started silently ignoring it, so entries saved with no interests at all. Also: stop calling `suggest-categories` (dropped from the form schema — its keyword matching was too noisy), and interests are now optional (0 allowed), not required 1–2.

---

## Short version — just the calls

```
GET    /api/users/me/activities/form-schema       → field list + type/role dropdowns
GET    /api/users/me/activities                   → list user's activities
POST   /api/users/me/activities                   → create activity
PATCH  /api/users/me/activities/:id               → update (partial)
DELETE /api/users/me/activities/:id               → delete

GET    /api/users/me/achievements/form-schema     → field list + level dropdown
GET    /api/users/me/achievements                 → list user's achievements
POST   /api/users/me/achievements                 → create achievement
PATCH  /api/users/me/achievements/:id             → update (partial)
DELETE /api/users/me/achievements/:id             → delete

POST   /api/media/presign                         → step 1 of media upload
PUT    <presignedUrl>                             → step 2 — direct upload to S3
                                                    (no step 3 needed for these flows —
                                                    pass the inline media shape in
                                                    the create/edit body and the
                                                    route attaches it server-side)
```

**Interest = one of the 9 categories, submitted as a slug.** The user picks **0–2**
from the `topics` multiselect; `field.options` holds the whole list inline, and
each option's `value` is the category's **slug** (`"sports-fitness"`, not
`"Sports & Fitness"`). Submit them as **`topicSlugs`** — the same field posts use.
See [topic-category-matching.md](../../topic-category-matching.md) for why.

The flow on both screens is identical:

1. `GET …/form-schema` → render the field list. `topics` options are **inline**
   (no separate fetch). Cache for the session.
2. If the user uploads photos → use the two-phase media flow.
3. `POST` (create) or `PATCH` (edit) with the assembled body — including
   `topicSlugs: [...]` (0–2).

---

## 1 — Activities

### 1.1 — Form schema

```http
GET /api/users/me/activities/form-schema
Authorization: Bearer <accessToken>
```

**Response:**

```jsonc
{
  "success": true,
  "data": {
    "fields": [
      {
        "name": "title",
        "label": "Title",
        "type": "text",
        "required": true,
        "maxLength": 120,
        "placeholder": "e.g., School football team",
      },
      {
        "name": "type",
        "label": "Activity Type",
        "type": "select",
        "required": true,
        "options": [
          { "value": "club_organisation", "label": "Club / Organisation" },
          { "value": "project", "label": "Project" },
          { "value": "competition", "label": "Competition" },
          { "value": "internship", "label": "Internship / Work" },
          { "value": "volunteer", "label": "Volunteer" },
          { "value": "course", "label": "Course / Program" },
          { "value": "event", "label": "Event / Workshop" },
          { "value": "research", "label": "Research / Publication" },
          { "value": "other", "label": "Other" },
        ],
        "otherValue": "other",
        "otherInput": {
          "label": "Activity Description",
          "placeholder": "e.g., Chess Club",
          "maxLength": 60,
          "required": true,
          "submitAs": "typeOther",
        },
      },
      {
        "name": "role",
        "label": "Role",
        "type": "select",
        "required": true,
        "dependsOn": "type",
        "optionsByDependency": {
          "club_organisation": [
            /* Member, Core Team, Lead, … */
          ],
          "project": [
            /* Creator, Contributor, Lead, … */
          ],
          "competition": [
            /* Participant, Team Member, … */
          ],
          "internship": [
            /* Intern, Trainee, Assistant, … */
          ],
          "volunteer": [
            /* Volunteer, Contributor, Organiser, … */
          ],
          "course": [
            /* Participant, Student, Learner, … */
          ],
          "event": [
            /* Attendee, Participant, Volunteer, … */
          ],
          "research": [
            /* Researcher, Author, Co-author, … */
          ],
          "other": [{ "value": "other", "label": "Other" }],
        },
        "otherValue": "other",
        "otherInput": {
          "label": "Describe your role",
          "placeholder": "e.g., Lead Mentor",
          "maxLength": 80,
          "submitAs": "role",
        },
      },
      {
        "name": "description",
        "label": "Description",
        "type": "textarea",
        "required": false,
        "maxLength": 500,
        "placeholder": "What did you do? What did you learn?",
      },
      {
        "name": "topics",
        "label": "Interests",
        "type": "multiselect",
        "required": false,
        "maxItems": 2,
        "options": [
          { "value": "academics-learning", "label": "Academics & Learning",
            "category": "Academics & Learning", "icon": null },
          { "value": "sports-fitness", "label": "Sports & Fitness",
            "category": "Sports & Fitness", "icon": null },
          /* … the 9 categories, inline — no separate fetch */
        ],
        "submitAs": "topicSlugs",
        "searchEndpoint": "GET /api/users/topics?q=",
      },
      {
        "name": "media",
        "label": "Photos",
        "type": "media",
        "required": false,
        "maxItems": 8,
        "acceptedTypes": ["image"],
      },
    ],
    "dropdowns": {
      "type": [
        /* same as fields[type].options */
      ],
      "rolesByType": {
        /* same as fields[role].optionsByDependency */
      },
      "categories": [
        /* the raw category labels — display only, NOT submittable */
      ],
      "topics": [
        /* same as fields[topics].options — this IS the selectable unit */
      ],
    },
  },
}
```

#### Rendering rules

| Field type    | UI                                                          |
| ------------- | ----------------------------------------------------------- |
| `text`        | Single-line input. Enforce `maxLength`.                     |
| `textarea`    | Multi-line input. Enforce `maxLength`.                      |
| `select`      | Dropdown picker.                                            |
| `multiselect` | Chip / tag picker.                                          |
| `media`       | Photo picker, enforce `maxItems` & `acceptedTypes`. |
| `url`         | URL input.                                                  |
| `date`        | Date picker. Submit as ISO `YYYY-MM-DD`.                    |

#### `select` with `otherValue` + `otherInput`

When the picked option's `value` === `otherValue`:

1. Show a text input with `otherInput.label` / `placeholder` / `maxLength`
   (and honor `otherInput.required`).
2. **Submit the typed string under `otherInput.submitAs`.** For `type`, the
   sentinel `"other"` stays in `type` **and** the free text goes in a separate
   `typeOther` field (the design's "Activity Description"). So picking `Other`
   and typing `Chess Club` sends `"type": "other", "typeOther": "Chess Club"`.
   `typeOther` is **required** when `type === "other"` (server returns
   `400 TYPE_OTHER_REQUIRED` otherwise) and ignored for any concrete type.

#### `select` with `dependsOn` + `optionsByDependency`

- `role.dependsOn === "type"`: when the value of `type` changes, look up
  `optionsByDependency[currentType]` for role's option list.
- If `type` is empty or its value isn't a key in the map, **clear the role
  field** and disable the dropdown.
- If `type` is the free-text "Other" value (any string not in
  `dropdowns.type`), use `optionsByDependency.other` (just `Other`).

#### `multiselect` (the `topics` field)

- Options are **inline** (`field.options`) — no `source` fetch. Render each using
  `label`; submit an **array** of `value` (the **slug**) under `submitAs` — e.g.
  `"topicSlugs": ["sports-fitness"]`.
- **0–2** (`maxItems: 2`). Optional on both screens — omit the field or send `[]`.
- What comes back in `topics[]` is exactly what you sent, resolved to full topic
  objects. No keyword inference on top: if you send one slug you get one topic.
- Send an entry with **no** topics and `shareToFeed: true` and the server may
  auto-tag the *shared post* with at most 2 categories from its text. The entry
  itself stays untagged.

### 1.2 — Create activity

```http
POST /api/users/me/activities
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "title": "School football team",
  "type": "club_organisation",        // sentinel "other" + typeOther when Other
  "role": "captain",                  // or free string if user picked Other
  "description": "Led the school team to district finals.",
  "topicSlugs": ["sports-fitness"],     // 0–2 slugs from form-schema topics.options
  "media": [
    { "type": "image", "url": "https://cdn.../photo.jpg", "width": 1080, "height": 1080 }
  ],
  "shareToFeed": true,                  // optional — also publish as a feed post
  "shareCaption": "Proud of the season!" // optional — overrides the auto post body
}
```

> If `type === "other"`, also send `"typeOther": "<free text>"` (required).
> `shareToFeed`/`shareCaption` are optional — see **Share to feed** below.

**Response: `201`**

```jsonc
{
  "success": true,
  "data": {
    "id": "act_...",
    "userId": "usr_...",
    "title": "School football team",
    "type": "club_organisation",
    "role": "captain",
    "description": "...",
    "order": 0,
    "createdAt": "...",
    "updatedAt": "...",
    "media": [
      {
        "id": "med_...",
        "type": "image",
        "url": "...",
        "width": 1080,
        "height": 1080,
      },
    ],
    "topics": [
      { "id": "top_...", "slug": "sports",   "name": "Sports",   "icon": "..." },
      { "id": "top_...", "slug": "teamwork", "name": "Teamwork", "icon": "..." },
    ],
    "sharedPostId": "post_..." // present (id) when shareToFeed=true, else null
  },
}
```

Server validates against `activityItemSchema`. **Required:** `title` (≤ 120),
`type`, `role`. `typeOther` is required when `type === "other"`. `topicSlugs`,
`description` and `media` are optional. `type`/`role` accept any string
(≤ 60 / 80) so the "Other" free-text submits cleanly.

`topics[]` in the response is **what you submitted**, resolved (`source="USER_SELECTED"`,
`confidence` 1.0). `topicCategories[]` is the server-derived label for each —
**response-only**, never send it. Unknown slugs are dropped silently rather than
rejected, so a stale cached picker degrades instead of failing. The legacy
`category` string column is unused by this flow.

### 1.3 — Update activity

```http
PATCH /api/users/me/activities/:id
Authorization: Bearer <accessToken>
Content-Type: application/json
```

Send any subset of the create fields. Two diff rules:

- **Media** — Omit `media` → unchanged. Send `media` → **replaces** the
  current set (URLs present kept, missing dropped + storage cleared, new
  appended). A re-order with no add/remove still requires the full array.
- **Topics** — send `topicSlugs` → it **replaces** the whole set (send `[]` to
  clear). Omit it → topics untouched, even if you change the title. Media- or
  title-only edits never touch topics or weights. Weights are **never decreased**
  (dropping an interest doesn't lower anything).

### 1.4 — List & delete

```http
GET    /api/users/me/activities
DELETE /api/users/me/activities/:id
```

**List response:** `{ success: true, data: [...] }` — each row has the same
shape as the create response (inline row fields + `media[]` + `topics[]`).
Ordered by `order` ASC then `createdAt` ASC.

**Delete:** `{ success: true }`. Cascades media (rows + storage blobs) and
topic-join rows via FK.

---

## 2 — Achievements

### 2.1 — Form schema

```http
GET /api/users/me/achievements/form-schema
Authorization: Bearer <accessToken>
```

**Response:**

```jsonc
{
  "success": true,
  "data": {
    "fields": [
      {
        "name": "title",
        "label": "Title",
        "type": "text",
        "required": true,
        "maxLength": 120,
      },
      {
        "name": "level",
        "label": "Level",
        "type": "select",
        "required": true,
        "options": [
          { "value": "school", "label": "School" },
          { "value": "regional", "label": "Regional" },
          { "value": "district", "label": "District" },
          { "value": "national", "label": "National" },
          { "value": "international", "label": "International" },
        ],
      },
      {
        "name": "awardedAt",
        "label": "Date",
        "type": "date",
        "required": true,
      },
      {
        "name": "description",
        "label": "Description",
        "type": "textarea",
        "required": false,
        "maxLength": 500,
      },
      {
        "name": "topics",
        "label": "Interests",
        "type": "multiselect",
        "required": false,
        "maxItems": 2,
        "options": [
          /* the 9 categories, inline — same as activities */
        ],
        "submitAs": "topicSlugs",
        "searchEndpoint": "GET /api/users/topics?q=",
      },
      {
        "name": "media",
        "label": "Photos",
        "type": "media",
        "required": false,
        "maxItems": 8,
        "acceptedTypes": ["image"],
      },
    ],
    "dropdowns": {
      "level": [
        /* same as fields[level].options */
      ],
      "categories": [
        /* raw labels — display only, NOT submittable */
      ],
      "topics": [
        /* same as fields[topics].options — this IS the selectable unit */
      ],
    },
  },
}
```

Same rendering rules as activities. No `dependsOn` / `otherValue` here —
`level` is a closed enum, and `topics` behaves exactly like the activity
`topics` field (0–2, inline options, `submitAs: "topicSlugs"`).

> `issuer` and `certificateUrl` are **no longer in the form** (dropped from the
> design). They're still accepted by the API as legacy optional fields, but
> don't render inputs for them.

### 2.2 — Create achievement

```http
POST /api/users/me/achievements
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "title": "District Math Olympiad — Gold",
  "level": "district",
  "awardedAt": "2025-11-12",
  "description": "Top score across 800 participants.",
  "topicSlugs": ["academics-learning"],   // 0–2 slugs from form-schema
  "media": [
    { "type": "image", "url": "https://cdn.../medal.jpg" }
  ],
  "shareToFeed": true,               // optional — also publish as a feed post
  "shareCaption": "So happy about this!" // optional — overrides the auto post body
}
```

**Required:** `title`, `level`, `awardedAt`. `topicSlugs` (0–2), `description`
and `media` are optional. `issuer` / `certificateUrl` still accepted (legacy) but
not part of the form. `shareToFeed`/`shareCaption` optional — see **Share to
feed** below.

**Response: `201`** — shape mirrors the activities create response, including
`topics: [{ id, slug, name, icon }]` (what you submitted, resolved),
`topicCategories: ["Academics & Learning"]` (derived, response-only) and
`sharedPostId` (the created post's id when `shareToFeed=true`, else `null`).

### 2.3 — Update achievement

```http
PATCH /api/users/me/achievements/:id
```

Partial body. Same media + topic diff rules as activities (§1.3): sending
`topicSlugs` replaces `topics[]` (send `[]` to clear); omitting it leaves topics
alone even when the title changes. Weights never decrease.

### 2.4 — List & delete

```http
GET    /api/users/me/achievements
DELETE /api/users/me/achievements/:id
```

Same response shape as activities — each row has inline fields + `media[]` +
`topics[]`. Delete cascades media + topic-join rows.

---

## 2.5 — Share to feed (the "Share with others" toggle)

Both create screens have a **"Share with others"** toggle. When on, send
`shareToFeed: true` in the create body and the server — in the **same
transaction** as the entry — also publishes a feed post that snapshots the whole
entry. The 201 response returns `sharedPostId` (the new post's id, or `null`
when the toggle is off). Optionally send `shareCaption` (≤ 2000 chars) to set the
post's lead text; omit it and the body defaults to `title` + `description`.

- **Post type** is a dedicated kind — `SHARED_ACTIVITY` or `SHARED_ACHIEVEMENT`.
  These are **internal**: they render in the feed and on the author's profile
  timeline but never appear in the post-composer picker
  (`GET /api/posts/types` omits them).
- **Snapshot, not a live link.** Sharing is **create-only** and captures the
  entry as it was. Editing the activity later does **not** update the post, and
  **deleting the activity keeps the post** (its media blob is preserved).
- The post carries the entry's **media** (same photos/videos) and the same
  **interests** (`topics[]`), exactly like any other post.

The full field set lives in the post's `details` JSON — render the feed card from
it. Shape (a `SHARED_ACTIVITY` example):

```jsonc
{
  "id": "post_...",
  "type": "SHARED_ACTIVITY",
  "title": "School football team",
  "content": "Proud of the season!",     // shareCaption, or title + description
  "author": { "id": "usr_...", "username": "...", "displayName": "...", "avatarUrl": "..." },
  "media": [ { "id": "med_...", "type": "image", "url": "...", "width": 1080, "height": 1080 } ],
  "topics": [ { "id": "top_...", "slug": "sports", "name": "Sports", "icon": "..." } ],
  "details": {
    "source": { "kind": "activity", "id": "act_..." }, // the origin entry
    "activity": {                                       // every form field, snapshotted
      "title": "School football team",
      "type": "club_organisation",
      "typeOther": null,
      "role": "captain",
      "description": "Led the school team to district finals.",
      "organisation": null,
      "duration": null,
      "area": null,
      "category": null,
      "startDate": null,
      "endDate": null,
      "isOngoing": false,
      "location": null
    }
  }
  // …plus the usual post fields (_count, cta:null, isLiked, isSaved, createdAt, …)
}
```

For a `SHARED_ACHIEVEMENT`, `details.achievement` carries
`{ title, level, awardedAt, description, area, issuer, category, certificateUrl }`.

`details` does **not** duplicate `media`/`topics` — read those from the post's
own `media[]` / `topics[]`. The two shared kinds have no request/CTA flow
(`cta: null`).

---

## 3 — Interests

The interest picker on both screens is the `topics` multiselect from form-schema
— **0–2 values**, options inline, submitted as `topicSlugs`. Each `value` is a
category **slug**.

```jsonc
// field.options — the whole list, inline
[
  { "value": "academics-learning",       "label": "Academics & Learning" },
  { "value": "arts-creativity",          "label": "Arts & Creativity" },
  { "value": "business-leadership",      "label": "Business & Leadership" },
  { "value": "competitive-activities",   "label": "Competitive Activities" },
  { "value": "entertainment-pop-culture","label": "Entertainment & Pop Culture" },
  { "value": "gaming-digital-culture",   "label": "Gaming & Digital Culture" },
  { "value": "lifestyle",                "label": "Lifestyle" },
  { "value": "sports-fitness",           "label": "Sports & Fitness" },
  { "value": "technology-innovation",    "label": "Technology & Innovation" }
]
```

`GET /api/users/topics` returns the same 9 (cached 1h), and `?q=<term>` filters
them — use it only if you want a search box over such a short list.

> **Not the onboarding picker.** Onboarding still selects **fine-grained topics**
> (`?representative=1`, ~86 of them, submitted as `interestSlugs`) — a different
> list for a different screen. See
> [mobile-topics-interests-guide.md](./mobile-topics-interests-guide.md).

> **Keyword suggestions are gone.** `POST /api/users/topics/suggest-categories`
> still responds, but don't build against it — it matched on characters, not
> meaning, and the suggestions were often unrelated to the title. Semantic
> matching will bring a suggestion step back later; the request field
> (`topicSlugs`) won't change when it does.

## 4 — Media upload (two-phase)

Same flow as everywhere else in the app. **Don't use** the multipart
`POST /api/media/upload` route except as a fallback — it costs the API a
proxy hop.

### 4.1 — Presign

```http
POST /api/media/presign
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "fileName": "medal.jpg",
  "mimeType": "image/jpeg",
  "fileSize": 482910
}
```

**Response:**

```jsonc
{
  "success": true,
  "data": {
    "uploadUrl": "https://s3.../...?X-Amz-Signature=...",
    "publicUrl": "https://cdn.../medal.jpg",
    "objectKey": "...",
  },
}
```

### 4.2 — Direct PUT to `uploadUrl`

```http
PUT <uploadUrl>
Content-Type: image/jpeg
<binary body>
```

No auth header on this one (signature carries auth). **Images only** (jpeg/png/
webp max 10 MB; heic/heif iOS photos max 15 MB) — video is rejected at presign
(`415`). Presign now requires the file `size` (bytes); your PUT's
`Content-Length` must match it, or storage returns `403`.

### 4.3 — Attach (optional during create)

For activity / achievement create + edit you can **skip** `/api/media/attach`
and pass the inline shape directly inside `media[]`:

```jsonc
{
  "type": "image", // image ONLY — video/gif are rejected
  "url": "<publicUrl>",
  "width": 1080, // optional but recommended
  "height": 1080,
  "size": 482910, // bytes
  "altText": "Gold medal photo",
}
```

The route's `attachMediaToOwner()` helper creates the polymorphic Media row
keyed to the activity/achievement and marks it auto-approved.

---

## 5 — Validation cheat-sheet

| Field                    | Rule                                                        |
| ------------------------ | ----------------------------------------------------------- |
| `title`                  | required, 1–120 chars                                       |
| `topicSlugs`             | optional, **0–2** category slugs (from form-schema options)  |
| `type` (activity)        | **required**, ≤ 60 chars ("other" sentinel or option value) |
| `typeOther` (activity)   | **required iff `type === "other"`**, ≤ 60 chars             |
| `role` (activity)        | **required**, ≤ 80 chars                                    |
| `level` (achievement)    | **required** (school/regional/district/national/international) |
| `awardedAt` (achievement)| **required**, ISO date (`YYYY-MM-DD`)                       |
| `description`            | optional, ≤ 500 chars                                       |
| `issuer`,`certificateUrl`| optional (legacy — not in form); URL ≤ 2048 for the latter  |
| `media[].url`            | required per item, valid URL                                |
| `media[]` count          | ≤ 8 items                                                   |
| `shareToFeed`            | optional bool — also publish a SHARED_ACTIVITY/ACHIEVEMENT feed post |
| `shareCaption`           | optional, ≤ 2000 chars — overrides the auto post body       |

Validation errors come back as `400` with a Zod-style payload:

```jsonc
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "issues": [{ "path": ["certificateUrl"], "message": "Invalid url" }],
  },
}
```

Surface the first issue per field next to the input.

---

## 6 — Caching tips

- **Form schemas** are deterministic per release — fetch once on screen
  mount, cache for the session, refetch on app foreground after a long
  background.
- **`/api/users/topics`** is `max-age=3600` — honor the `Cache-Control`
  header.
- **`rolesByType`** is embedded in the form-schema response — no separate
  call needed when `type` changes; just swap the option list in-memory.

---

## 7 — Open items (backend will follow up)

- Discovery endpoints ("users with similar activities", "find people who did
  the same achievement") are not yet exposed — the (now keyword-inferred)
  ActivityTopic / AchievementTopic join rows already power them server-side.
- Clients send `topicSlugs` (request) and read `topics[]` + the derived
  `topicCategories[]` (response). The legacy `category` string column is unused
  by this flow.
- **Fine-grained topics come back later, on semantic matching.** When they do,
  `field.options` simply carries the finer list — the request field, the limit
  (2) and the response shape stay exactly as documented here, so nothing you
  build now has to be rewritten.
- Achievement-list reorder endpoint not yet exposed; reorder is one PATCH per
  row using the `order` field for now.
