# Mobile Posts — Feed & Detail Data Structure

> For mobile rendering. This explains **every field** the API returns on a post
> — in the feed, in a filtered list, and on a single post-detail screen — so you
> can decide what to show and which buttons (CTAs / actions) to enable based on
> **who is looking**: the post's author, or a third person (any other viewer).
>
> This is a read guide. It does not tell you how to *create* posts (see
> `mobile-posts-guide.md`) or the request/DM flow mechanics (see
> `mobile-post-requests-guide.md` and `mobile-post-request-dm-guide.md`). It
> tells you what the JSON means.

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

---

## 1 — One post shape, everywhere

Every endpoint that returns posts runs them through **one** serializer, so the
post object looks the same whether it came from the feed, a profile, saved
bookmarks, or a single-post fetch. Learn the shape once.

**Endpoints that return the list shape** (array of posts):

```
GET /api/posts                 → filtered list        { data: { items, nextCursor } }
GET /api/posts/me              → my posts
GET /api/posts/user/:username  → someone's posts
GET /api/posts/saved           → my bookmarks
GET /api/feed                  → personalized feed
```

**Endpoints that return the single shape** (one post):

```
GET  /api/posts/:id            → one post             { data: <post> }
POST /api/posts                → after create         { data: <post> }
POST /api/posts/:id/repost     → after repost         { data: <post> }
```

Response envelope is always `{ success: true, data: ... }`. List endpoints wrap
posts as `{ items: [<post>, ...], nextCursor: string | null }`.

**The only difference between the two shapes:**

| Field      | List / feed | Single detail (`GET /:id`) |
| ---------- | ----------- | -------------------------- |
| `comments` | ❌ not sent | ✅ full comment array sent |

Everything else is identical. So the feed gives you a comment **count**
(`_count.comments`) but not the comments themselves; open the detail screen to
get the actual comment list.

---

## 2 — The two roles

Several fields change value depending on the viewer. There are exactly two
roles, decided by comparing the logged-in user's id to `authorId`:

- **Author** — `authorId === your user id`. This is your own post.
- **Third person** — everyone else. Any other logged-in viewer.

The server already resolves role-dependent fields for you (`cta.buttonLabel`,
`_count.requests`, `viewerRequestStatus`, …). You do **not** re-compute them —
you read them and render. The role comparison is only useful so you understand
*why* a value looks the way it does, and to pick which action buttons to show.

Quick rule:

```
const isAuthor = post.authorId === currentUser.id;
```

---

## 3 — Full field reference

Every key on a post object. Fields are grouped for reading; the JSON is flat
(except nested objects noted below).

### 3.1 — Core content (same for everyone)

| Field           | Type                         | Meaning                                                                                                       |
| --------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `id`            | string                       | The post's unique id.                                                                                        |
| `type`          | string enum                  | Post kind: `FIND_TEAMMATES`, `INVITE_PEOPLE`, `START_SOMETHING`, `SHARE_OPPORTUNITY`, `ANYTHING`, `REPOST`. Drives which optional fields are filled and whether a CTA exists. |
| `title`         | string \| null               | Post title. `null` on kinds that don't use one (e.g. plain `ANYTHING`, `REPOST`).                            |
| `content`       | string                       | The body text. Can be `""` (empty) for media-only `ANYTHING` posts and always empty for simple reposts.      |
| `location`      | string \| null               | Free-text location. `null` if unset.                                                                        |
| `locationMode`  | string \| null               | `CITY` \| `SCHOOL` \| `ONLINE` \| `HYBRID`. Only Start-Something sets this. `null` otherwise.                |
| `eventAt`       | ISO date-time \| null        | When the thing happens. Set on Find-Teammates / Invite / Opportunity. `null` if none.                        |
| `deadlineAt`    | ISO date-time \| null        | Request cut-off. Required on Find-Teammates / Invite-People (pre-existing rows may still be `null`).         |
| `link`          | string \| null               | External URL (Share-Opportunity / Start-Something). `null` if none.                                         |
| `details`       | object \| null               | Free-form escape-hatch JSON for future per-kind fields. Usually `null`.                                     |
| `isHidden`      | boolean                      | Author hid the post. Hidden posts don't reach third-person viewers at all (they 404), so you'll normally see this `false`. |
| `createdAt`     | ISO date-time                | When posted.                                                                                                |
| `updatedAt`     | ISO date-time                | Last edit time.                                                                                             |
| `authorId`      | string                       | The author's user id. Compare with the current user to get the role.                                       |

### 3.2 — Author block

`author` is a nested object describing who posted it. Same for every viewer.

| Field                    | Type           | Meaning                                                     |
| ------------------------ | -------------- | ---------------------------------------------------------- |
| `author.id`              | string         | Author user id (same as `authorId`).                       |
| `author.username`        | string         | Handle.                                                    |
| `author.grade`           | string \| null | Author's current grade (e.g. `"5th"`). `null` if unset.    |
| `author.school`          | string \| null | Author's current school name. `null` if unset.             |
| `author.profile`         | object \| null | Profile card. `null` if the user has no profile row.       |
| `author.profile.displayName` | string    | Shown name. Prefer this over `username` for display.       |
| `author.profile.avatarUrl`   | string \| null | Avatar image URL. `null` → render initials/placeholder. |

### 3.3 — Topics & media (same for everyone)

`topics` — array of tag objects attached to the post:

| Field        | Type           | Meaning                    |
| ------------ | -------------- | -------------------------- |
| `id`         | string         | Topic id.                  |
| `slug`       | string         | URL-safe key.              |
| `name`       | string         | Display label.             |
| `icon`       | string \| null | Icon name. `null` if none. |

`media` — array of image/video rows (empty array if none):

| Field              | Type           | Meaning                                             |
| ------------------ | -------------- | --------------------------------------------------- |
| `id`               | string         | Media id.                                           |
| `type`             | string         | `image` \| `video` \| `gif`.                        |
| `url`              | string         | The **full-resolution original**. Can be several MB — do not load this in the feed (see 3.3.1). |
| `width`            | number \| null | Pixel width of the original. **Often `null`** — prefer a variant's `width` for layout (see 3.3.1). |
| `height`           | number \| null | Pixel height of the original. Same caveat as `width`. |
| `altText`          | string \| null | Accessibility text. `null` if none.                 |
| `processingStatus` | string         | `pending` \| `processing` \| `complete` \| `failed`. Governs whether `variants` is populated (see 3.3.1). |
| `variants`         | array          | Resized renditions (thumbnail/small/medium/large). Empty until `processingStatus === "complete"`. |

### 3.3.1 — Image variants & which URL to render

**Rule: never render `media.url` in the feed.** `url` is the untouched original
the user uploaded — routinely 3–5 MB. Loading many of those in a scrolling list
causes intermittent decode/memory failures (blank images that "fix themselves"
when you open the detail screen, then blank again on the way back). Render a
**variant** instead; keep `url` for the detail/full-screen view only.

Each processed image exposes up to four variants. `variants` is ordered
alphabetically by `variantType` (large, medium, small, thumbnail) — **look up by
`variantType`, never by array index.**

Each variant object:

| Field         | Type   | Meaning                                          |
| ------------- | ------ | ------------------------------------------------ |
| `variantType` | string | `thumbnail` \| `small` \| `medium` \| `large`.   |
| `url`         | string | Render this. WebP.                               |
| `width`       | number | Actual pixel width of the variant.               |
| `height`      | number | Actual pixel height of the variant.              |
| `sizeBytes`   | number | Byte size — always small (KB range).             |

| variantType | Size            | Use for                                             |
| ----------- | --------------- | --------------------------------------------------- |
| `thumbnail` | 240×240 (square crop) | Grid tiles, avatars-of-media, dense multi-image collages. |
| `small`     | ≤750px wide     | **Feed card default.**                              |
| `medium`    | ≤1080px wide    | Single-image feed card on large phones / tablets.   |
| `large`     | ≤1440px wide    | Post-detail screen.                                 |
| (original)  | `media.url`     | Full-screen / pinch-to-zoom only.                   |

Videos (`type: "video"`) get only a `thumbnail` variant (a 240×240 poster
frame). Play `media.url` when the user taps.

**Picking the URL (pseudocode):**

```
function feedImageUrl(m):
  v = m.variants.find(x => x.variantType === "small")
      ?? m.variants.find(x => x.variantType === "medium")
      ?? m.variants.find(x => x.variantType === "thumbnail")
  return v?.url ?? m.url        // fall back to original only if no variants yet
```

**Dimensions for aspect-ratio boxing:** take `width`/`height` from the variant
you chose, **not** from top-level `media.width`/`media.height` — those are often
`null` even after processing. This lets you reserve the correct box and avoid
layout jump. If you fell all the way back to `media.url` and its dims are null,
use a sensible default ratio (e.g. 4:5) until the image loads.

**While `processingStatus !== "complete"`:** `variants` is empty. Two options —
(a) show a placeholder/skeleton until a later fetch returns variants, or (b)
render `media.url` directly as a stopgap (works, just heavy). The background
processor normally fills variants within seconds of upload; a `failed` status
means it never will (retry/report, don't spin forever).

### 3.4 — Counts

`_count` — a small object of totals:

| Field      | Type   | Meaning                                                                                                            |
| ---------- | ------ | ----------------------------------------------------------------------------------------------------------------- |
| `likes`    | number | Total likes.                                                                                                      |
| `comments` | number | Total comments (top-level + replies).                                                                             |
| `reposts`  | number | Total reposts of this post.                                                                                       |
| `shares`   | number | Total times shared.                                                                                               |
| `requests` | number | **Role-dependent.** Pending request count. Shows the real number **only to the author**; a third person always sees `0`. Use it to render the author's "N new requests" badge. |

### 3.5 — Viewer state (this logged-in user's private relationship to the post)

These describe *your* relationship to the post. For a third person they reflect
that viewer's own taps; for the author, some don't apply (see notes).

| Field             | Type            | Meaning                                                                                                              |
| ----------------- | --------------- | ------------------------------------------------------------------------------------------------------------------- |
| `isLiked`         | boolean         | Did **this viewer** like it. Drives the filled/empty heart.                                                          |
| `isSaved`         | boolean         | Did this viewer bookmark it. Private — never shown to others. Drives the filled/empty bookmark.                      |
| `interestStatus`  | string \| null  | This viewer's recommendation feedback: `"interested"`, `"not_interested"`, or `null` (none). Not settable on your own post. |
| `viewerRequestStatus` | string \| null | This viewer's request state on the post: `PENDING`, `ACCEPTED`, `REJECTED`, `WITHDRAWN`, or `null` (never asked). Always `null` for the author (you don't request your own post). |
| `viewerRequestConversationId` | string \| null | The DM opened when this viewer's request was **accepted**. This is the navigation target for the "Open chat" button. `null` unless `viewerRequestStatus === "ACCEPTED"` **and** a DM was successfully linked. |

### 3.6 — Request prompt block (only meaningful on CTA kinds)

These come straight off the post. They describe the little questionnaire a
third person fills when they tap the CTA. On CTA-disabled kinds they are empty.

| Field                       | Type           | Meaning                                                                                     |
| --------------------------- | -------------- | ------------------------------------------------------------------------------------------- |
| `requestPrompt`             | string \| null | The question shown to a requester, e.g. `"Why are you a good fit?"`.                         |
| `requestPromptDescription`  | string \| null | Helper line under the prompt.                                                                |
| `requestOptions`            | string[]       | The multiple-choice chips a requester can pick from. Empty on non-CTA kinds.                |
| `requestPromptSource`       | string \| null | Where the prompt came from: `DEFAULT` (catalog default), `AUTHOR` (author customized), `AI`, or `null`. Informational only. |
| `requestState`             | string          | Author's intake gate: `OPEN` (accepting), `PAUSED` (temporarily off), `COMPLETED` (permanently closed). Drives the CTA label for fresh viewers — see §4. |

### 3.7 — CTA object (role-resolved — read §4)

`cta` — the single most important object for buttons. It is **pre-resolved for
the current viewer**. See the next section for the full breakdown.

### 3.8 — Repost fields

Present on every post; only interesting for reposts or posts that have been
reposted.

| Field            | Type           | Meaning                                                                                                                                 |
| ---------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `originalPostId` | string \| null | On a `type: REPOST` row, points at the original post. `null` + `type: REPOST` together mean the original was deleted → render "Original removed". `null` on a normal post. |
| `original`       | object \| null | Embedded summary of the reposted original (so you can render the quoted card without a second fetch). `null` if not a repost, or original deleted. Shape: `{ id, type, title, content, createdAt, author, media }`. |
| `isReposted`     | boolean        | Did **this viewer** already repost this post (one-tap simple repost). Drives the filled/active repost icon.                          |
| `viewerRepostId` | string \| null | The id of this viewer's own repost row. Pass it (or the original id) to `DELETE /api/posts/:id/repost` to undo. `null` if not reposted. |
| `repostedBy`     | object \| null | Social proof: mutuals of this viewer who reposted it. `null` if none. Shape: `{ users: [{ id, username, profile }], mutualCount }` — up to 3 named users, newest first, plus the total mutual count. May lag follow changes by up to 5 min. |

### 3.9 — Comments (single-detail only)

Only present on `GET /api/posts/:id`. Array of comments, each with its
`author` block (`id`, `username`, `profile`), ordered oldest-first. In the
feed you get only `_count.comments`.

---

## 4 — The `cta` object (author vs third person)

This is where role-based rendering lives. The server has **already** picked the
right label and enabled/disabled state for the current viewer. You render what
it says — no per-role branching needed for the label itself.

### 4.1 — Fields

| Field           | Type    | Meaning                                                                                              |
| --------------- | ------- | ---------------------------------------------------------------------------------------------------- |
| `enabled`       | boolean | Does this post kind have a request CTA at all. `false` for `SHARE_OPPORTUNITY`, `ANYTHING`, `REPOST`. When `false`, show no request button. |
| `verb`          | string  | The action verb: `join` / `attend` / `collaborate`. Used in copy.                                    |
| `buttonLabel`   | string  | **The label to show on the button, already resolved for this viewer.** This is the field you render. |
| `actionable`    | boolean | Whether the button should be **tappable**. `false` → render it disabled/informational (e.g. "Requested", "Closed", "Approved"). |
| `pendingLabel`  | string  | Raw label for the pending state (the resolver may have already put it in `buttonLabel`).             |
| `acceptedLabel` | string  | Raw label for accepted state.                                                                        |
| `rejectedLabel` | string  | Raw label for rejected state.                                                                        |
| `authorLabel`   | string  | Raw label the author sees ("View Responses").                                                         |
| `pausedLabel`   | string  | Raw label for a fresh viewer when intake is paused.                                                   |
| `completedLabel`| string  | Raw label for a fresh viewer when intake is closed.                                                   |

> You mostly only need `enabled`, `buttonLabel`, and `actionable`. The raw
> per-state labels are there if you want to build your own copy.

### 4.2 — How `buttonLabel` + `actionable` are resolved

The server walks these rules in order and stops at the first match:

| Viewer situation                                | `buttonLabel` becomes | `actionable` | What to render                          |
| ----------------------------------------------- | --------------------- | ------------ | --------------------------------------- |
| CTA disabled (`enabled: false`)                 | `""`                  | `false`      | No request button at all.               |
| **Author**                                      | `authorLabel` → "View Responses" | `true` | Tap → open the requests inbox for this post. |
| Third person, `viewerRequestStatus = PENDING`   | `pendingLabel` → "Requested" | `true` | Already asked; can re-submit/withdraw.   |
| Third person, `viewerRequestStatus = ACCEPTED`  | `acceptedLabel` → "Open chat" | `false` | Tap → open DM at `viewerRequestConversationId`. |
| Third person, `viewerRequestStatus = REJECTED`  | `rejectedLabel` → "Request Declined" | `false` | Disabled; cannot re-ask.          |
| Third person, fresh, `requestState = PAUSED`    | `pausedLabel` → "Requests Paused" | `false` | Disabled.                          |
| Third person, fresh, `requestState = COMPLETED` | `completedLabel` → "Closed" | `false` | Disabled.                                |
| Third person, fresh, `requestState = OPEN`      | `buttonLabel` → "Request to Join/Attend/Collaborate" | `true` | Tap → open the request form. |

> Note the exception: an **ACCEPTED** viewer's `buttonLabel` is "Open chat" but
> `actionable` is `false` — "not actionable" means "cannot submit a request",
> not "cannot tap". When the label is "Open chat", navigate to
> `viewerRequestConversationId`. Treat "Open chat" as the one special tap on an
> otherwise non-request button.

### 4.3 — Simplest rendering logic

```js
if (!post.cta.enabled) {
  // no request CTA for this kind — skip the button
} else if (isAuthor) {
  // button: post.cta.buttonLabel ("View Responses")
  // badge:  post._count.requests  (pending count, author-only)
  // tap → GET /api/posts/:id/requests
} else if (post.viewerRequestStatus === "ACCEPTED") {
  // button: "Open chat" — tap → open post.viewerRequestConversationId
} else {
  // button: post.cta.buttonLabel
  // enabled: post.cta.actionable
  // tap (if actionable) → open the request form
}
```

---

## 5 — Per-kind cheat sheet

Which fields are populated and whether a CTA exists, by `type`.

| Kind (`type`)       | Has CTA? | `verb`      | Typical filled fields                                        |
| ------------------- | -------- | ----------- | ----------------------------------------------------------- |
| `FIND_TEAMMATES`    | ✅ yes   | join        | title, content, topics, eventAt, deadlineAt, (opt) media/location |
| `INVITE_PEOPLE`     | ✅ yes   | attend      | title, content, topics, location, eventAt, deadlineAt, (opt) media/link |
| `START_SOMETHING`   | ✅ yes   | collaborate | title, content, topics, (opt) media/location/locationMode/link |
| `SHARE_OPPORTUNITY` | ❌ no    | —           | title, content, topics, location, eventAt, (opt) media/link |
| `ANYTHING`          | ❌ no    | —           | topics, then content **or** media (one required); (opt) location |
| `REPOST`            | ❌ no    | —           | empty title/content; `originalPostId` + `original` embed carry the quoted post |

- CTA-enabled kinds (`FIND_TEAMMATES`, `INVITE_PEOPLE`, `START_SOMETHING`) are
  the only ones with a real `cta`, `requestPrompt*`, `requestOptions`, and a
  meaningful `requestState`. Everything else has `cta.enabled: false` and empty
  request fields.
- `REPOST`: render the reposter (`author`) as "X reposted", then render the
  `original` card underneath. If `original` is `null` and `type` is `REPOST`,
  show "Original removed".

---

## 6 — Which actions to show (by role)

Data tells you role and state; here's the button set each role gets. All are
separate endpoints — this guide only says *when* to show them.

**Author (your own post):**

- Edit → `PATCH /api/posts/:id`
- Delete → `DELETE /api/posts/:id`
- View responses → `GET /api/posts/:id/requests` (badge from `_count.requests`)
- Pause / Resume / Complete intake → `PATCH /api/posts/:id/request-state`
  (only on CTA kinds; reflect current `requestState`)
- Accept / Reject / Reopen a request → `PATCH /api/posts/:id/requests/:reqId`
- Like / Save / Comment / Share — allowed on your own post.
- **Not** available: request-to-join, repost your own post, set interest on your
  own post (the API rejects these).

**Third person (any other viewer):**

- Like → `PUT /api/posts/:id/like` (state in `isLiked`)
- Save → `PUT /api/posts/:id/save` (state in `isSaved`)
- Interested / Not interested → `PUT /api/posts/:id/interest` (state in `interestStatus`)
- Repost → `POST /api/posts/:id/repost` / undo `DELETE …/repost` (state in `isReposted` / `viewerRepostId`)
- Share → `POST /api/posts/:id/share`
- Comment → `POST /api/posts/:id/comments`
- Request CTA — only if `cta.enabled` **and** `cta.actionable`:
  - Submit → `POST /api/posts/:id/requests`
  - Withdraw → `DELETE /api/posts/:id/requests/me` (when `viewerRequestStatus = PENDING`)
  - Open chat → navigate to `viewerRequestConversationId` (when `ACCEPTED`)

---

## 7 — Saved list: unavailable shells

`GET /api/posts/saved` is special. If you bookmarked a post that later became
unviewable (deleted, hidden, or the author went private), the item comes back as
a **shell** instead of a full post, so you can show "no longer available" with a
tap-to-remove. Detect it by the `isUnavailable` flag:

```json
{ "id": "<postId>", "isUnavailable": true, "unavailableReason": "DELETED" }
```

| `unavailableReason` | Meaning                                          |
| ------------------- | ------------------------------------------------ |
| `DELETED`           | The post no longer exists.                        |
| `HIDDEN_BY_AUTHOR`  | The author hid it.                                |
| `RESTRICTED`        | The author's profile went private; you can't see it. |

Shells have **only** `id`, `isUnavailable`, `unavailableReason` — no `author`,
`cta`, etc. Always check `isUnavailable` before reading other fields on
saved-list items. Every other list endpoint returns full posts only.

---

## 8 — Gotchas

- **`_count.requests` is `0` for non-authors** — never use it to show request
  counts to a third person. It's author-only by design (no inbox-size leakage).
- **`viewerRequestConversationId` can be `null` even when `ACCEPTED`** — if chat
  was blocked or DM linkage failed. If it's `null`, fall back to opening the
  post instead of the chat.
- **`cta` object is always present** even on non-CTA kinds — check
  `cta.enabled` first; when `false`, its labels are empty strings.
- **`original` / `originalPostId` both `null` on a `REPOST`** → the original was
  deleted; render "Original removed".
- **Feed has no `comments` array** — use `_count.comments` for the number and
  fetch `GET /api/posts/:id` (or the comments endpoint) to show them.
- **Don't recompute role-based labels** — `cta.buttonLabel`, `actionable`, and
  `_count.requests` are already resolved server-side for the requesting user.
