# Mobile Posts — Integration Guide

> For the mobile development. Everything needed to create, edit, read, list,
> like, and comment on posts. No backend changes needed on your side.
>
> **Access control:** the private-profile gate (per
> `docs/private-profile-implementation.md`) gates every post by its
> author's profile-privacy state. Restricted profiles return 403 on
> follower/following lists, and posts by private authors return 404 to
> non-followers. Saved-list entries by an author who later flips private
> come back as shells with `isUnavailable: true` rather than dropping.
> Per-post visibility (Everyone / Friends) is **deferred** to a future
> sprint — see `docs/post-visibility-implementation.md`.

- API base URL (local): `http://localhost:3001`
- All requests are JSON. Send `Content-Type: application/json`.
- Send `X-Client-Type: mobile` on every request.
- Auth: `Authorization: Bearer <accessToken>` on every endpoint below (except
  the S3 PUT for media upload).

## 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-10** — No client impact: the share link's landing page moved from Cloudflare Pages to Firebase Hosting. Share URLs, the `?ref=` code and everything in §18 are unchanged; only the "Domain + TLS" row in §18.7 says a different vendor.
- **2026-09-09** — Share URLs changed shape: `POST /api/posts/:id/share` now returns `https://letsclustr.com/posts/<id>?ref=<code>`. The `/p/<id>` form documented here before was wrong — it was never served by anything and no longer exists. The link now really does open the app (or a download page) once the app claims the domain; profiles are shareable too via `GET /api/users/:username/share`. Full contract: [mobile-deep-links-guide.md](../deep-links/mobile-deep-links-guide.md).
- **2026-09-07** — The topic picker is now the **9 interest categories**, not the ~1130 fine-grained topics. Nothing changes in the request: still `topicSlugs`, still 1–2, still slugs from `GET /api/users/topics` — that call just returns 9 rows now (`"sports-fitness"`, `"academics-learning"`, …). Don't hardcode the list; fine-grained topics come back later on semantic matching and only the option list will change.
- **2026-09-01** — `topicSlugs` is now optional on `ANYTHING` posts — leave it out or send `[]`, both work. The other four post kinds still need 1–2 topics. Also corrected here: the field table said 1–10 topics; the real limit is 2, and has been since the topic taxonomy shipped.

---

## Short version — just the API calls

```
0. GET    /api/posts/types                → post-type catalog (summary: 5 kinds)
   GET    /api/posts/types/:slug          → full detail for one kind (fields + CTA + defaults)
   GET    /api/users/topics                → interest/topic catalog
1. POST   /api/media/presign               → { uploadUrl, publicUrl }   (per file)
   PUT    <uploadUrl>                      (file bytes, direct to S3)
2. POST   /api/posts                       → create post
3. GET    /api/posts/:id                   → single post
4. GET    /api/posts/me                    → current user's posts
5. GET    /api/posts/user/:username        → another user's posts
6. GET    /api/posts?type=&topicSlug=...   → filtered list
7. GET    /api/feed                        → personalized feed
8. PATCH  /api/posts/:id                   → edit a post
9. DELETE /api/posts/:id                   → delete a post
10. PUT    /api/posts/:id/like             → set like state (idempotent, body: {liked})
11. PUT    /api/posts/:id/save             → set save state (idempotent, body: {saved})
12. GET    /api/posts/saved                → viewer's saved posts (private bookmarks)
13. POST   /api/posts/:id/repost           → one-tap repost (empty body)
14. DELETE /api/posts/:id/repost           → undo repost
15. GET    /api/posts/:id/reposts          → list of repost rows
16. POST   /api/posts/:id/share            → record share + return tracked URL
17. GET    /api/posts/:id/share/preview    → share-sheet prefill (title/desc/image)
18. GET    /api/posts/:id/comments         → list comments + replies
19. POST   /api/posts/:id/comments         → add comment / reply
```

All catalogs (`/posts/types`, `/posts/types/:slug`, `/users/topics`) send
`Cache-Control: public, max-age=3600`. Cache them locally for the session.

---

## 1 — Post types (drive your form)

Two-call split:

- `GET /api/posts/types` → summary list. Drives the type picker.
- `GET /api/posts/types/:slug` → full detail for one kind. Drives the form
  on selection (fields, CTA, request-prompt defaults).

### 1.1 — Summary list

```jsonc
{
  "success": true,
  "data": [
    {
      "kind": "FIND_TEAMMATES",
      "slug": "find-teammates",
      "name": "Find Teammates",
      "description": "Build a team or find partners for something you want to do",
      "icon": "users",
      "order": 1,
    },
    {
      "kind": "INVITE_PEOPLE",
      "slug": "invite-people",
      "name": "Invite People",
      "description": "Get others to join something you're hosting",
      "icon": "mail",
      "order": 2,
    },
    {
      "kind": "START_SOMETHING",
      "slug": "start-something",
      "order": 3 /* ... */,
    },
    {
      "kind": "SHARE_OPPORTUNITY",
      "slug": "share-opportunity",
      "order": 4 /* ... */,
    },
    { "kind": "ANYTHING", "slug": "anything", "order": 5 /* ... */ },
  ],
}
```

**Use the catalog as the source of truth.** Don't hardcode the kind list — it
WILL change. Render the picker from `data[]` ordered by `order`.

### 1.2 — Per-kind detail (`GET /api/posts/types/:slug`)

Call this on kind selection (or pre-warm all five on cold-start — they're 1h
cached). Returns fields, CTA, request-prompt defaults:

```jsonc
{
  "success": true,
  "data": {
    "kind": "FIND_TEAMMATES",
    "slug": "find-teammates",
    "name": "Find Teammates",
    "description": "Build a team or find partners for something you want to do",
    "icon": "users",
    "order": 1,
    "fields": {
      "required": [
        "title",
        "content",
        "topicSlugs",
        "eventAt",
        "locationMode",
        "deadlineAt",
      ],
      "optional": ["media", "location", "locationPlace", "link"],
    },
    "cta": {
      "enabled": true,
      "verb": "join",
      "buttonLabel": "Request to Join",
      "pendingLabel": "Request Sent",
      "acceptedLabel": "Joined",
      "rejectedLabel": "Request Declined",
      "authorLabel": "View Responses",
    },
    "defaultRequestPrompt": "Why are you a good fit?",
    "defaultRequestPromptDescription": "Pick what best describes you so the creator can review responses quickly.",
    "defaultRequestOptions": ["I have experience", "I'm interested", "..."],
  },
}
```

Render only fields in `required ∪ optional`. Mark required ones. 404 on
unknown slug.

Per-kind field sets:

| slug                | required                                                        | optional                               |
| ------------------- | --------------------------------------------------------------- | -------------------------------------- |
| `find-teammates`    | `title, content, topicSlugs, eventAt, locationMode, deadlineAt` | `media, location, locationPlace, link` |
| `invite-people`     | `title, content, topicSlugs, eventAt, locationMode, deadlineAt` | `media, location, locationPlace, link` |
| `start-something`   | `title, content, topicSlugs, locationMode`                      | `media, location, locationPlace, link` |
| `share-opportunity` | `title, content, topicSlugs, location, eventAt`                 | `media, link`                          |
| `anything`          | _(none)_                                                        | `content, media, location, topicSlugs` |

> **The 3 CTA kinds carry an Online / In-person toggle** (`locationMode`). It's
> required, but `location` / `locationPlace` / `link` show as _optional_ above
> because which one is required depends on the toggle — the server enforces the
> real rule (see §1.3). This mirrors `anything`, whose `required` list is empty
> while the server still requires content-or-media (§4.5).

Field meanings:

| field name      | type              | sent as                                                                                                           |
| --------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------- |
| `title`         | string ≤120       | body.title                                                                                                        |
| `content`       | string ≤2000      | body.content (description body)                                                                                   |
| `topicSlugs`    | string[] (1–2)    | body.topicSlugs — category slugs from `/api/users/topics` (§2). Required on the 4 CTA/event kinds; **optional on `anything`** (§4.5)  |
| `media`         | object[] (≤10)    | body.media — see §3                                                                                               |
| `location`      | string ≤200       | body.location — display string (in-person only)                                                                   |
| `locationMode`  | enum              | body.locationMode — `ONLINE` \| `IN_PERSON` on the 3 CTA kinds (§1.3)                                             |
| `locationPlace` | object            | body.locationPlace — structured Places blob incl. `city` (in-person, §1.3). Returned only by `GET /api/posts/:id` |
| `eventAt`       | ISO 8601 datetime | body.eventAt                                                                                                      |
| `deadlineAt`    | ISO 8601 datetime | body.deadlineAt — required on `find-teammates` / `invite-people`                                                  |
| `link`          | URL               | body.link — meeting URL (online) or RSVP/details URL                                                              |

> **`eventAt` / `deadlineAt` are one instant — not a date plus a time.** Merge the
> date picker and the time picker in device-local time, then send a single ISO
> string:
>
> ```ts
> const at = new Date(2026, 7, 30, 1, 0); // Aug 30, 1:00 am device-local
> eventAt: at.toISOString(); // "2026-08-29T19:30:00.000Z"
> ```
>
> Sending only the date value gives you **midnight** — `2026-08-29T18:30:00.000Z`
> is Aug 30 00:00 IST — and the time the user picked is gone.
>
> **There is no `time` field**, and unknown keys are silently dropped: a body
> carrying `"eventAt": "...T18:30:00.000Z", "time": "1:00 am"` returns **201**
> with the event stored at midnight. No validation error will tell you.
>
> Both columns are stored and returned as **UTC with no timezone attached** —
> every client renders them in its own device timezone. A same-region audience
> sees the time the author meant; a viewer in another timezone sees it shifted.

### 1.3 — Online / In-person toggle (the 3 CTA kinds only)

`FIND_TEAMMATES`, `INVITE_PEOPLE`, `START_SOMETHING` each require a
`locationMode` toggle with exactly two choices:

- **`ONLINE`** (virtual) → show a **link** field. `link` is **required**;
  `location` / `locationPlace` must be **absent**.
- **`IN_PERSON`** → show a **location** field. `location` is **required**;
  `link` is **optional** (some events add an RSVP/details URL), and
  `locationPlace` is optional-but-expected.

`link` is valid in **both** modes. The two modes are mutually exclusive — there
is no hybrid.

**Location data (in-person).** The client picks a place via the Google Places
API and sends both a human-readable `location` string (for display) and a
structured `locationPlace` blob (for the detail-page map pin + future "events
near me"):

```jsonc
"location": "Ocean Beach, San Francisco, CA",
"locationPlace": {
  "placeId": "ChIJ...",           // Google Place ID
  "name": "Ocean Beach",          // optional
  "formattedAddress": "Ocean Beach, San Francisco, CA 94121",
  "lat": 37.7594,
  "lng": -122.5107,
  "city": "San Francisco",        // SEND THIS — it's what the feed renders
  "region": "California",         // optional
  "country": "United States"      // optional
}
```

> **Always send `city`.** Take it from the Place Details `address_components`
> (`locality`, falling back to `postal_town` / `administrative_area_level_2`).
> It is the only location the feed shows — see the precision rule below. It's
> optional in the schema so older builds keep working, but without it the feed
> falls back to whatever free text you put in `location`, which may be a street
> address.

Validation errors (HTTP 400) when the invariant is broken:

| you sent                        | error                                          |
| ------------------------------- | ---------------------------------------------- |
| `ONLINE` with no `link`         | `link is required for online posts`            |
| `ONLINE` with a `location`      | `location is not allowed on online posts`      |
| `ONLINE` with a `locationPlace` | `locationPlace is not allowed on online posts` |
| `IN_PERSON` with no `location`  | `location is required for in-person posts`     |

> `locationMode` values `CITY` / `SCHOOL` / `HYBRID` are **legacy** — old
> START_SOMETHING rows may still carry them and render fine, but the composer
> only ever sends `ONLINE` / `IN_PERSON`. The response `locationPlace` is
> `null` on online posts and on any legacy/pre-toggle row.

**Location precision differs by endpoint.** This is a privacy rule, not a
payload optimisation — a scrollable feed must not publish the exact coordinates
of a kid's meetup:

| Surface                                                               | `location`                            | `locationPlace`             |
| --------------------------------------------------------------------- | ------------------------------------- | --------------------------- |
| `GET /api/posts`, `/me`, `/saved`, `/user/:userName`, `GET /api/feed` | **locality only** — `"San Francisco"` | **absent**                  |
| `GET /api/posts/:id`                                                  | exact, as the author entered it       | full blob incl. `lat`/`lng` |

The coarse value is `locationPlace.city` when you sent one, otherwise the
author's typed `location`. It applies to the author's own posts too — the author
sees exact values by opening the post. `locationMode` is present on every
surface, so the Online / In-person badge still renders on feed cards.

Practically: **feed cards have no map pin and no maps deep link** (there are no
coordinates to build one from) — render the locality as plain text. Fetch the
post detail before offering "open in Maps".

**Viewer render on the detail page — tap to open Maps.** A viewer sees
`post.location` as **tappable text**; the tap opens the device Maps app. Build
the deep link client-side from the fields you already have — the server does
**not** ship a maps URL (it can't pick the native app per-platform):

- **Has `locationPlace`** (precise pin):
  `https://www.google.com/maps/search/?api=1&query=<lat>,<lng>&query_place_id=<placeId>`
  → opens the exact place. iOS-native alternative:
  `https://maps.apple.com/?ll=<lat>,<lng>&q=<name>`. RN: `Linking.openURL(...)`,
  or `geo:<lat>,<lng>?q=<lat>,<lng>(<name>)` on Android.
- **No `locationPlace`** (author typed free text):
  `https://www.google.com/maps/search/?api=1&query=<encodeURIComponent(post.location)>`
  → text search fallback.

For **online** posts there's no location — render `post.link` as the joinable
meeting URL instead.

---

## 2 — Topics dropdown (interest tags)

`GET /api/users/topics` — **the 9 interest categories**. Cached 1h; fetch once
per session and reuse. Same response shape as everywhere else.

> Onboarding's interest step is a **different** list — `?representative=1`, ~86
> fine-grained topics. Don't share one cached list between the two screens. See
> [mobile-topics-interests-guide.md](../user-actions/mobile-topics-interests-guide.md).

```jsonc
{
  "success": true,
  "data": [
    {
      "id": "tpc_xxx",
      "slug": "sports-fitness",
      "name": "Sports & Fitness",
      "category": "Sports & Fitness",
      "icon": null,
    },
    {
      "id": "tpc_yyy",
      "slug": "arts-creativity",
      "name": "Arts & Creativity",
      "category": "Arts & Creativity",
      "icon": null,
    },
    // ... 9 in total
  ],
}
```

Send the chosen `slug` values back as `topicSlugs: ["sports-fitness","arts-creativity"]`
— the **slug**, never the display name. Unknown slugs are silently dropped
server-side, so a create with only bad slugs succeeds with no topics; validate
against this list client-side.

---

## 3 — Media flow

Same presign flow as onboarding. Two steps per file:

### 3.1 — Presign

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

{
  "files": [
    { "filename": "photo1.jpg", "contentType": "image/jpeg", "size": 245678 }
  ]
}
```

> **Posts are image-only** (`image/jpeg`, `image/png`, `image/webp`, max 10 MB;
> `image/heic`/`image/heif` iOS photos, max 15 MB). Video and gif are rejected.
> `size` (exact byte length) is **required** — the server caps it and pins it as
> the signed `Content-Length`.

Response:

```jsonc
{
  "success": true,
  "data": {
    "files": [
      {
        "uploadUrl": "https://s3.../signed?...",
        "publicUrl": "https://s3.../uploads/abc.jpg",
      },
    ],
  },
}
```

### 3.2 — PUT to S3

```http
PUT <uploadUrl>
Content-Type: image/jpeg    ← MUST match what you sent to /presign
Content-Length: 245678      ← MUST match the presigned size (exact)

<binary bytes>
```

No auth headers — the signed URL is the credential. Storage rejects a body whose
length isn't exactly the presigned `size` (`403`).

### 3.3 — Include media inline on create

Pass each uploaded file in the `media[]` array of the create body. Server
creates the `Media` rows polymorphically (`ownerType=post`) in the same
request.

```jsonc
"media": [
  {
    "type": "image",          // image ONLY (video/gif rejected on posts)
    "url": "https://s3.../uploads/abc.jpg",
    "thumbnailUrl": "https://...",   // optional
    "width": 1080,            // optional
    "height": 1920,           // optional
    "size": 245678,           // optional, bytes
    "altText": "Team photo"   // optional
  }
]
```

> Max 10 media per post. Media is set at **create** time only — editing a
> post cannot add media. To remove existing items, PATCH with `removeMediaIds`
> (see §6).

---

## 4 — Creating a post

Same endpoint for every kind. The `type` field discriminates. Server
validates per-kind required set.

### 4.1 — Find Teammates

```jsonc
POST /api/posts
{
  "type": "FIND_TEAMMATES",
  "title": "Looking for a 4th for our hackathon team",
  "content": "Building a flashcard app. Need a designer or another dev.",
  "topicSlugs": ["technology-innovation"],
  "eventAt": "2026-06-12T10:00:00Z",        // event date
  "deadlineAt": "2026-06-10T23:59:59Z",     // deadline
  "locationMode": "IN_PERSON",              // required — ONLINE | IN_PERSON (§1.3)
  "location": "San Francisco",              // required when IN_PERSON
  "locationPlace": {                         // structured Places blob (in-person)
    "placeId": "ChIJ...", "formattedAddress": "San Francisco, CA",
    "lat": 37.7749, "lng": -122.4194,
    "city": "San Francisco"                  // what the feed renders (§1.3)
  },
  "media": [ { "type": "image", "url": "..." } ]
}
```

> Online variant: `"locationMode": "ONLINE", "link": "https://meet.example/x"`
> and **no** `location` / `locationPlace`. See §1.3 for the full rule.

### 4.2 — Invite People

```jsonc
{
  "type": "INVITE_PEOPLE",
  "title": "Beach cleanup this Saturday",
  "content": "Bring gloves. We'll provide bags.",
  "topicSlugs": ["business-leadership"],
  "eventAt": "2026-05-25T09:00:00Z",
  "deadlineAt": "2026-06-10T23:59:59Z", // deadline
  "locationMode": "IN_PERSON", // required — ONLINE | IN_PERSON (§1.3)
  "location": "Ocean Beach, San Francisco", // required when IN_PERSON
  "locationPlace": {
    "placeId": "ChIJ...",
    "formattedAddress": "Ocean Beach, San Francisco, CA",
    "lat": 37.7594,
    "lng": -122.5107,
    "city": "San Francisco", // what the feed renders (§1.3)
  },
  "link": "https://rsvp.example/beach", // optional — link allowed in-person
}
```

### 4.3 — Start Something New

```jsonc
{
  "type": "START_SOMETHING",
  "title": "Starting a robotics club",
  "content": "Anyone interested in meeting weekly to build robots?",
  "topicSlugs": ["technology-innovation", "competitive-activities"],
  "locationMode": "ONLINE", // required — ONLINE | IN_PERSON (§1.3)
  "link": "https://discord.gg/robotics", // required when ONLINE; no location
}
```

> In-person variant: `"locationMode": "IN_PERSON"` with `location`
> (+ `locationPlace`) instead of `link`.

### 4.4 — Share an Opportunity

```jsonc
{
  "type": "SHARE_OPPORTUNITY",
  "title": "STEM scholarship — applications open",
  "content": "Open to grades 9–12. $5000 award.",
  "topicSlugs": ["academics-learning"],
  "location": "Online",
  "eventAt": "2026-07-01T23:59:59Z", // deadline / event time
  "link": "https://example.org/apply", // optional external URL
}
```

### 4.5 — Post Anything

```jsonc
{
  "type": "ANYTHING",
  "content": "Just had the best burrito of my life.",
  "media": [{ "type": "image", "url": "..." }],
}
```

`type` is the only structurally required field. **`topicSlugs` is optional on
this kind only** — omit the key, or send `[]`; both mean the same thing and the
server derives the feed signal itself from the text. Send 1–2 slugs when the
author picked topics and those win over the derived ones. Every other kind
still rejects a create without `topicSlugs` (400).

The one rule the server does enforce: **content or media** — a post with
neither is a 400 on `content` (`"Add some text or at least one photo/video"`).

### 4.6 — Response shape (all kinds)

> This is the **detail** shape (create / update / `GET /api/posts/:id`). List
> and feed responses are identical except for location: `location` holds the
> locality only and `locationPlace` is absent — see §1.3.

```jsonc
{
  "success": true,
  "data": {
    "id": "pst_xxx",
    "type": "FIND_TEAMMATES",
    "title": "...",
    "content": "...",
    "location": "...", // exact here; locality-only on list/feed (§1.3)
    "locationMode": null,
    "locationPlace": null, // detail only — never present on list/feed
    "eventAt": "2026-06-12T10:00:00Z",
    "deadlineAt": "2026-06-10T23:59:59Z",
    "link": null,
    "details": null,
    "authorId": "usr_yyy",
    "isHidden": false,
    "createdAt": "2026-05-19T12:00:00Z",
    "updatedAt": "2026-05-19T12:00:00Z",
    "author": {
      "id": "usr_yyy",
      "username": "alice",
      "profile": { "displayName": "Alice", "avatarUrl": "..." },
    },
    "topics": [
      { "id": "tpc_xxx", "slug": "coding", "name": "Coding", "icon": "code" },
    ],
    "media": [
      {
        "id": "med_zzz",
        "type": "image",
        "url": "...",
        "thumbnailUrl": "...",
        "width": 1080,
        "height": 1920,
        "altText": null,
      },
    ],
    "_count": { "likes": 0, "comments": 0 },
    "isLiked": false,
  },
}
```

**The response shape is identical across all kinds.** Kind-specific columns
are `null` when not applicable. This means your detail screen / list cell
can use one render path with conditional sub-views by `type`.

---

## 5 — Reading posts

### 5.1 — Single post

```
GET /api/posts/:id
```

Returns the same shape as create. Includes top-level `comments[]` (just the
first batch — paginate via §11 for more).

### 5.2 — Current user's posts

```
GET /api/posts/me?limit=20&cursor=<base64>
```

### 5.3 — Another user's posts

```
GET /api/posts/user/:username?limit=20&cursor=<base64>
```

404 if username doesn't exist.

### 5.4 — List with filters

```
GET /api/posts?type=FIND_TEAMMATES&topicSlug=coding&upcomingOnly=true&limit=20
```

Query params (all optional, all combine with AND):

| param            | meaning                                                                           |
| ---------------- | --------------------------------------------------------------------------------- |
| `type`           | `FIND_TEAMMATES`/`INVITE_PEOPLE`/`START_SOMETHING`/`SHARE_OPPORTUNITY`/`ANYTHING` |
| `topicSlug`      | only posts tagged with this topic                                                 |
| `authorUsername` | only posts by this user                                                           |
| `upcomingOnly`   | `"true"` → require `eventAt >= now()`                                             |
| `cursor`         | from previous response's `nextCursor`                                             |
| `limit`          | default `20`, max `50`                                                            |

### 5.5 — Personalized feed

```
GET /api/feed?limit=20&cursor=<id>
```

Unchanged from before. Blends follows + interests + trending. Items have
the same shape as list endpoints plus `score`, `pool`, `mode`.

### 5.6 — List response shape

```jsonc
{
  "success": true,
  "data": {
    "items": [
      /* array of full post objects, same shape as §4.6 */
    ],
    "nextCursor": "eyJj...", // or null if no more pages
  },
}
```

Pass `nextCursor` back as `?cursor=` to fetch the next page. Stop when
`null`.

---

## 6 — Updating a post

```
PATCH /api/posts/:id
```

All fields optional — omit a field to leave it unchanged. `type` is
**immutable** — cannot change after create.

### 6.1 — Per-type field validation

Edits are validated against the post's type using the same catalog that
drives your create form (`GET /api/posts/types/:slug`,
`fields.required` / `fields.optional`):

| the field is…              | value sent              | `null` sent                 |
| -------------------------- | ----------------------- | --------------------------- |
| **required** for the type  | applied                 | **400** — cannot be cleared |
| **optional** for the type  | applied                 | applied — clears the field  |
| **not in** the type's form | **400** — not supported | **silently ignored**        |

The null-is-ignored rule means you can PATCH your whole flat form state —
sending `null` for every field the type's form doesn't render — without
triggering errors. A 400 only fires when you send an actual **value** for a
field the type doesn't have (e.g. `deadlineAt` on a START_SOMETHING post),
which indicates a client bug.

### 6.2 — Per-type edit matrix

| type                | editable, can't clear (`null` → 400)                            | editable + clearable (`null` clears) | rejected if value sent (`null` ignored)          |
| ------------------- | --------------------------------------------------------------- | ------------------------------------ | ------------------------------------------------ |
| `FIND_TEAMMATES`    | `title, content, topicSlugs, eventAt, locationMode, deadlineAt` | `location, locationPlace, link`      | —                                                |
| `INVITE_PEOPLE`     | `title, content, topicSlugs, eventAt, locationMode, deadlineAt` | `location, locationPlace, link`      | -                                                |
| `START_SOMETHING`   | `title, content, topicSlugs, locationMode`                      | `location, locationPlace, link`      | `eventAt, deadlineAt`                            |
| `SHARE_OPPORTUNITY` | `title, content, topicSlugs, location, eventAt`                 | `link`                               | `locationMode, deadlineAt`                       |
| `ANYTHING`          | `topicSlugs, content`                                           | `location`                           | `title, locationMode, eventAt, deadlineAt, link` |
| `REPOST`            | —                                                               | —                                    | all typed fields (reposts aren't editable)       |

Rules that apply to **every** type:

- `topicSlugs` REPLACES the topic set (never merged, cannot be `null`).
- **Media cannot be added on edit** (media is create-only). To remove existing
  media, pass `removeMediaIds` — an array of media `id`s (from the post's
  `media[]`). Ids that don't belong to this post are ignored. Removed rows are
  deleted along with their storage blobs.
- `requestPrompt` / `requestPromptDescription` / `requestOptions` — CTA-enabled
  kinds only (`FIND_TEAMMATES`, `INVITE_PEOPLE`, `START_SOMETHING`); frozen
  with **409** once the first request exists (§15.12). On non-CTA kinds a
  value → **400**, `null` → ignored.
- **Online / in-person invariant applies on edit too** (§1.3). The server
  checks the _merged_ result: e.g. flipping `locationMode` to `ONLINE` while a
  `location` is still set → **400** (`location is not allowed on online posts`).
  When flipping modes, send the new field and clear the other (`location: null`
  when going online, or `link: null` + `location: "..."` when going in-person).
  Only fires for `ONLINE` / `IN_PERSON`; legacy `CITY`/`SCHOOL`/`HYBRID` rows are
  unconstrained.
- **Time-bound posts freeze after the event.** Once `eventAt` has passed, any
  edit returns **409** ("Post can no longer be edited after its event date").
  Posts without `eventAt` never lock. Hide/disable the edit action when the
  event date is in the past.

### 6.3 — Examples

Edit a FIND_TEAMMATES post — move the event, move the deadline, remove a photo:

```jsonc
PATCH /api/posts/pst_xxx
{
  "title": "Updated title",
  "content": "Updated description",
  "topicSlugs": ["coding", "design", "art"],
  "eventAt": "2026-06-15T10:00:00Z",   // required for this type — value OK, null would 400
  "deadlineAt": "2026-06-14T10:00:00Z", // required for this type — value OK, null would 400
  "removeMediaIds": ["med_abc123"]
}
```

Edit a START_SOMETHING post — flat form dump, nulls for fields the form
doesn't render are fine:

```jsonc
PATCH /api/posts/pst_yyy
{
  "title": "Robotics club v2",
  "content": "Now meeting twice a week.",
  "topicSlugs": ["robotics"],
  "locationMode": "ONLINE",
  "location": null,        // optional for this type — clears it
  "eventAt": null,         // not in this type — ignored, no error
  "deadlineAt": null,      // not in this type — ignored, no error
  "link": null             // optional for this type — clears it
}
```

Sending `"deadlineAt": "2026-08-01T10:00:00Z"` (a real value) on that same
START_SOMETHING post → **400**
`"deadlineAt is not supported on START_SOMETHING posts"`.

### 6.4 — Errors

| code | meaning                                                                          |
| ---- | -------------------------------------------------------------------------------- |
| 400  | Value for a field the type doesn't have, or `null` for a required-for-type field |
| 403  | Not the post author                                                              |
| 404  | Post not found (or author private + viewer not approved)                         |
| 409  | Event date has passed, or request prompt frozen after first request (§15.12)     |

---

## 7 — Deleting a post

```
DELETE /api/posts/:id
```

Hard-delete. Cascades comments, likes, media (rows + storage blobs),
mentions. 403 if not author. 404 if not found.

---

## 8 — Like / unlike

```
PUT /api/posts/:id/like
Content-Type: application/json

{ "liked": true }       // or false to unlike
```

Idempotent. `{liked: true}` is a no-op if already liked; `{liked: false}` is
a no-op if not liked. The client sends the desired state directly, so the
server never has to reconstruct intent from arrival order.

Response:

```jsonc
{
  "success": true,
  "data": {
    "liked": true, // echoes the requested state
    "updatedAt": "2026-05-22T07:35:40.451Z",
  },
}
```

`isLiked` on the post object reflects the current user's state.
`_count.likes` on list / detail responses is the authoritative count — trust
it on refetch / hydration / pagination.

`GET /api/posts/:id/likes` returns the liker list, sorted with the viewer's
followers/following first.

### 8.1 — Optimistic UI (mandatory pattern)

Do **not** show a spinner on the like button. Apply the change locally on tap
and reconcile with the server in the background.

1. **On tap** — flip `isLiked` and bump `_count.likes` by `+1` (like) or `-1`
   (unlike) immediately in the client store. Record the new value as the
   desired state for this post.
2. **Fire** `PUT /api/posts/:id/like` with `{ liked: <desired> }` in the
   background. No `await` on the UI thread.
3. **On HTTP error** — roll back the local state and surface a toast
   ("Couldn't update — tap to retry"). Do not block other interactions.
4. **On feed refetch / detail screen open** — trust the server's
   `_count.likes` and `isLiked` as the source of truth; overwrite local
   state.

### 8.2 — Race conditions (rapid tap)

PUT is idempotent and carries the desired state in the body, so the server
no longer guesses intent from arrival order. But rapid taps can still produce
out-of-order responses on the wire, so:

- **Serialize per post** — keep at most one in-flight like mutation per
  postId. Cancel the previous one before issuing a new one. With TanStack
  Query: `queryClient.cancelQueries({ queryKey: ["post-like", postId] })`
  inside `onMutate`.
- **Track desired state** — store the user's latest intended `liked` value
  client-side. After each response, only apply it if its `updatedAt` is
  strictly newer than the last applied response for that post. Discard
  older `updatedAt` responses — they reflect an obsolete tap.
- **Idempotent retry on failure** — safe to re-send the same
  `{ liked: <desired> }` body. PUT is idempotent, so retrying never
  double-counts.

The endpoint is also rate-limited (per-user, 30 calls per 10s). Don't
re-fire on every render — debounce taps that happen faster than a human
can tap.

---

## 9 — Comments

### 9.1 — List

```
GET /api/posts/:id/comments?limit=20&cursor=<commentId>
```

Returns top-level comments, ordered newest first. **Single-level nesting
(Instagram-style):** each top-level comment carries a flat `replies[]` array
— replies are never themselves nested. **Auth required** (`Bearer` token) —
the response's per-comment `isLiked` is computed for the calling user.

```jsonc
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "cmt_xxx",
        "content": "...",
        "authorId": "...",
        "postId": "...",
        "parentId": null,
        "createdAt": "...",
        "author": {
          /* id, username, profile */
        },
        "likeCount": 3,
        "isLiked": false, // viewer's like state on this comment
        "replies": [
          {
            /* same shape (incl. likeCount + isLiked),
               parentId = the top-level comment id.
               replies[] is NOT present on these (flat — one level only). */
          },
        ],
      },
    ],
    "nextCursor": "cmt_yyy",
    "total": 42,
  },
}
```

### 9.2 — Create comment / reply

```jsonc
POST /api/posts/:id/comments
{
  "content": "@bob Sounds great!",   // client owns the text, incl. any @handle
  "parentId": "cmt_xxx"              // omit for top-level
}
```

**Single-level nesting + client-owned composer.** `parentId` may point at a
top-level comment _or_ at a reply — the server flattens threading to one level
either way (`parentId` collapses to the root comment) and records the reply
_relationship_ separately in `replyToUserId`.

- The server stores `content` **verbatim** — it never injects or re-adds an
  `@handle`. When the user taps "Reply", prefill `@<handle> ` yourself; the user
  may edit or delete it.
- Reply notification is driven by the **relationship, not the text**: the
  replied-to author always gets _"replied to your comment"_ (`type: comment`),
  even if the `@handle` was deleted. Skipped only on a self-reply.
- Other `@mentions` in the text notify those users (`type: mention`), **deduped**
  against the reply/comment recipient — one notification per person per comment.
- A top-level comment notifies the post author (_"commented on your post"_), with
  the same dedup.

The response echoes the stored `content`, `parentId` (root), and `replyToUserId`.
Render `@handle` tokens using the mention offsets (see §10) for tap-to-profile.

### 9.3 — Like a comment

```jsonc
PUT /api/posts/:postId/comments/:commentId/like
{ "liked": true }     // or false to unlike
```

Response — like state **after** the call:

```jsonc
{
  "success": true,
  "data": {
    "liked": true,
    "likeCount": 4,
    "updatedAt": "2026-06-30T07:35:40.451Z",
  },
}
```

Boolean like, one per user per comment (mirrors the post like). Works on
top-level comments and replies alike. Behaviors:

- **Idempotent** — repeating the same state is a no-op (`liked:true` when already
  liked stays `likeCount` unchanged). Safe to retry / double-tap.
- **Self-like allowed** — you can like your own comment (no notification fires).
- **Notification** — the comment author is notified only on the first
  unliked→liked transition (`type: "like"`, `data.entityType: "comment"`).
  Unliking and re-liking does not re-notify within the same row lifetime.
- **404** when the comment doesn't exist, isn't in `:postId`, or the post isn't
  visible to you.
- Rate-limited **30 / 10s** per user (`429`, no body).

**Optimistic UI**: flip `isLiked` + adjust `likeCount` locally on tap, fire the
PUT in the background, reconcile from `likeCount`/`updatedAt` on response, roll
back on HTTP error. Same pattern as the post like.

### 9.4 — Delete comment

```
DELETE /api/posts/:postId/comments/:commentId
```

403 if not comment author. Cascades replies (and their likes).

---

## 10 — Mentions (@username)

Server auto-detects `@username` in post + comment `content`. Mentioned users
get a notification. Behavior is identical to existing flows — no extra work
needed.

---

## 11 — Pagination cursor

All paginated list endpoints use base64url-encoded `(createdAt, id)`
cursors. Treat the value as opaque. Pass it back via `?cursor=...`. The
server returns `nextCursor: null` on the final page.

Comment lists use the comment ID as the cursor (simpler than time-based for
comment threads).

---

## 12 — Error responses

Validation errors → HTTP 400 with details:

```jsonc
{
  "success": false,
  "error": "Validation error",
  "details": [
    {
      "code": "invalid_type",
      "path": ["eventAt"],
      "message": "Required",
    },
  ],
}
```

Other errors:

| code | meaning                                                                             |
| ---- | ----------------------------------------------------------------------------------- |
| 401  | Missing or invalid `Authorization` header                                           |
| 403  | Not the author (edit / delete), OR target profile private + viewer not approved     |
| 404  | Post / user / comment not found, OR author profile is private + viewer not approved |
| 429  | Per-route rate limit (catalogs use a 60s/30req bucket)                              |
| 500  | Server error — capture `request_id` if present                                      |

**Private-profile gating:** when the author of a post has set
`profile.isPrivate=true` and the viewer is not an approved follower,
every post-scoped endpoint (`GET /api/posts/:id`, all sub-resources, all
write routes including like/save/comment) returns **404** — the post
"does not exist" for the viewer (Instagram behavior). Profile-list
endpoints (`/api/follows/:userId/{followers,following,friends}`,
`/api/posts/user/:userName`) return **403** with `{ error: "private_profile" }`.
See `docs/private-profile-implementation.md` for the full surface.

---

## 13 — When the post-type list changes

The list of post kinds is a product decision and may shift. Backend changes:

- Add a kind → server adds enum + catalog entry + validator arm.
- Drop a kind → server removes them.

On mobile, **re-fetch `/api/posts/types` after each app cold-start** (the
1h cache makes this cheap), and fetch `/api/posts/types/:slug` on kind
selection (or pre-warm all five). Don't ship a hardcoded list. The catalog
drives everything — picker labels, form fields, required-vs-optional, CTA
labels, request-prompt defaults.

---

## 14 — Open questions

> already on the radar:

- **Per-post visibility** (followers-only, friends-only, private) — not yet
  implemented; every post is public to authenticated users right now.
- **Drafts / scheduled posts** — not yet implemented.
- **Repost / share to chat** — not yet implemented.
- **Reactions beyond like** — only `like` exists today.

---

## 15 — Type-specific CTAs & Requests

Three of the five post kinds expose a request CTA:

| kind                | viewer CTA             | author CTA     |
| ------------------- | ---------------------- | -------------- |
| `FIND_TEAMMATES`    | Request to Join        | View Responses |
| `INVITE_PEOPLE`     | Request to Attend      | View Responses |
| `START_SOMETHING`   | Request to Collaborate | View Responses |
| `SHARE_OPPORTUNITY` | _(none)_               | _(none)_       |
| `ANYTHING`          | _(none)_               | _(none)_       |

All CTA labels are server-provided — **never hardcode them**. They come from
the per-kind detail (`GET /api/posts/types/:slug`) and are also inlined on
every post object as `post.cta`. The server flips `buttonLabel` based on
viewer state (author / pending / accepted / rejected / fresh).

### 15.1 — Where CTA + defaults live

`GET /api/posts/types/:slug` (see §1.2) returns the per-kind `cta` block and
the `defaultRequestPrompt` / `defaultRequestPromptDescription` /
`defaultRequestOptions`. The summary list (`GET /api/posts/types`) does
**not** include these — fetch the detail when the user picks a kind.

Use the defaults as form placeholders when a user is creating a CTA-enabled
post. If the author tweaks them, send the overrides on the create body.

### 15.2 — Authoring custom prompt/options

CTA-enabled create requests accept three optional fields:

```jsonc
POST /api/posts
{
  "type": "FIND_TEAMMATES",
  "title": "Looking for a Debate Partner",
  "content": "Need a 4th for state regionals.",
  "topicSlugs": ["debate"],
  "eventAt": "2026-04-25T10:00:00Z",
  "location": "San Jose, CA",
  "requestPrompt": "Why are you a good fit?",
  "requestPromptDescription": "Pick what best describes you so the creator can review responses quickly.",
  "requestOptions": [
    "I have experience",
    "I'm interested",
    "I'm available on those dates",
    "I'm highly motivated"
  ]
}
```

- Omit all three → server fills from catalog defaults, sets `requestPromptSource = "DEFAULT"`.
- Provide any of them → `requestPromptSource = "AUTHOR"`.
- Sending any of them on `SHARE_OPPORTUNITY` / `ANYTHING` → **400**.
- Once the first PostRequest row exists, `PATCH /api/posts/:id` rejects edits
  to these three fields with **409** (snapshot integrity for existing
  responders).

### 15.3 — Post response additions (every list + detail endpoint)

```jsonc
{
  // ...existing fields...
  "requestPrompt": "Why are you a good fit?",
  "requestPromptDescription": "Pick what best describes you so the creator can review responses quickly.",
  "requestOptions": ["I have experience", "I'm interested", "..."],
  "requestPromptSource": "DEFAULT", // DEFAULT | AUTHOR | AI | null
  "requestState": "OPEN", // OPEN | PAUSED | COMPLETED — author-controlled intake (CTA kinds; OPEN otherwise)
  "cta": {
    "enabled": true,
    "verb": "join",
    "buttonLabel": "Request to Join", // resolved for current viewer + requestState
    "pendingLabel": "Request Sent",
    "acceptedLabel": "Joined",
    "rejectedLabel": "Request Declined",
    "authorLabel": "View Responses",
    "pausedLabel": "Requests Paused", // shown to a fresh viewer when requestState=PAUSED
    "completedLabel": "Closed", // shown to a fresh viewer when requestState=COMPLETED
    "actionable": true, // false → render the pill disabled (no tap target)
  },
  "viewerRequestStatus": null, // PENDING | ACCEPTED | REJECTED | WITHDRAWN | null
  "_count": { "likes": 3, "comments": 5, "requests": 12 },
  //                                     ^^^^^^^^ pending count, author only (0 for others)
}
```

Mobile render rule for the CTA pill:

- `cta.enabled === false` → don't render the pill.
- otherwise render `cta.buttonLabel` — server has already swapped it for the
  author's "View Responses", a requester's "Request Sent" / "Joined" /
  "Request Declined", or (for a fresh viewer on a paused/closed post) the
  "Requests Paused" / "Closed" label.
- `cta.actionable === false` → render the pill **disabled** (greyed, no tap).
  True only when the viewer can actually act — a fresh viewer on an `OPEN`
  post, or the author (whose "View Responses" is always tappable).
- Use `requestState` to render an intake badge on the card (e.g. a "Paused" /
  "Closed" chip) independent of the viewer's own request status.

### 15.4 — Submit a request

```http
POST /api/posts/:id/requests
{
  "selectedOptions": ["I'm interested", "I'm available on those dates"],
  "message": "I've competed at state level for two years."  // optional, ≤500
}
```

- `selectedOptions` must be a subset of `post.requestOptions` (server validates
  per-string; sending an unknown option → **400**).
- Idempotent on `(postId, requesterId)`: re-submit after a WITHDRAWN/REJECTED
  flips the row back to PENDING and refreshes the answers.
- **400** if the post type has no CTA or if you're the post author.
- **409** if you're already ACCEPTED — withdrawing is a no-op at that point.
- **429** if you exceed 100 submissions in a rolling 24h window.

Response payload is the full PostRequest row including the embedded
`requester` summary.

### 15.5 — Withdraw your request

```http
DELETE /api/posts/:id/requests/me
```

Sets `status = WITHDRAWN` (audit trail preserved). Only PENDING rows can be
withdrawn — accepted/rejected → **409**.

### 15.6 — Author: view responses (the "View Responses" tap target)

```http
GET /api/posts/:id/requests?status=PENDING&limit=20&cursor=<base64>
```

Author-only. Returns the request inbox for one post.

```jsonc
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "req_xxx",
        "postId": "pst_yyy",
        "requesterId": "usr_zzz",
        "status": "PENDING",
        "selectedOptions": ["I'm interested", "I'm available on those dates"],
        "message": "I've competed at state level for two years.",
        "respondedAt": null,
        "respondedBy": null,
        "createdAt": "2026-05-19T12:00:00Z",
        "updatedAt": "2026-05-19T12:00:00Z",
        "requester": {
          "id": "usr_zzz",
          "username": "adam",
          "profile": { "displayName": "Adam", "avatarUrl": "..." },
        },
      },
    ],
    "nextCursor": null,
    "counts": { "pending": 12, "accepted": 3, "rejected": 1, "withdrawn": 2 },
  },
}
```

`counts` is unfiltered (always returns totals across every status) so you
can render tab badges. `items` honors the `status` filter.

### 15.7 — Author: accept / reject

```http
PATCH /api/posts/:id/requests/:reqId
{ "action": "accept" }    // or "reject"
```

Only PENDING rows can be transitioned. Server emits notification to
requester (`post_request_accepted` / `post_request_rejected`). No
side-effects beyond status change in v1 — no auto chat, no event join.

### 15.8 — Author: aggregate inbox across all your posts

```http
GET /api/posts/requests/inbox?status=PENDING&limit=20
```

Same response shape as §15.6 but each row also includes a `post` summary
(`{ id, type, title, content, authorId, eventAt, createdAt }`) so the list
can show "X requested to join _Looking for a Debate Partner_". Default
status filter is PENDING.

### 15.9 — Requester: my outgoing requests

```http
GET /api/posts/requests/mine?status=PENDING&limit=20
```

Returns the viewer's requests across every post they've ever requested,
with the same `post` summary embedded. No `counts` block.

### 15.10 — Notifications

| type                    | recipient          | trigger                                     |
| ----------------------- | ------------------ | ------------------------------------------- |
| `post_request`          | post author        | viewer submits or re-submits a request      |
| `post_request_accepted` | original requester | author accepts                              |
| `post_request_rejected` | original requester | author rejects                              |
| `post_deleted`          | original requester | author deletes a post that had pending reqs |

All payloads include `data.entityType="post"`, `data.entityId=<postId>`,
`data.postType=<PostType>`, plus `requestId` for the request-scoped ones.

### 15.11 — Error reference

| code | meaning                                                                                   |
| ---- | ----------------------------------------------------------------------------------------- |
| 400  | CTA disabled / self-request / selectedOption not in requestOptions                        |
| 403  | Not the post author (inbox / accept-reject)                                               |
| 404  | Post or request not found                                                                 |
| 409  | Already accepted, editing prompt after first request, or intake PAUSED/COMPLETED (§15.13) |
| 429  | Per-requester rate limit (100 submissions / rolling 24h)                                  |

### 15.12 — Snapshot freeze

Once the first PostRequest row exists for a post, the author cannot edit
`requestPrompt`, `requestPromptDescription`, or `requestOptions` — server
returns 409. This keeps every responder's `selectedOptions` stable against
the chip catalog they actually saw. To "change the question", post a new
one (or pause / complete the old one — see §15.13).

### 15.13 — Author: pause / resume / complete request intake

The author of a CTA-enabled post controls whether it accepts **new** requests.
This never touches existing requests — a paused or completed post keeps all its
PostRequest rows, and the author can still accept/reject anything already
PENDING (§15.7).

```http
PATCH /api/posts/:id/request-state
{ "action": "pause" }     // "pause" | "resume" | "complete"
```

- **pause** — stop accepting new requests (reversible).
- **resume** — reopen a paused post.
- **complete** — permanently close intake. **Terminal — cannot be undone.**

State machine (`requestState` on the post object):

| action     | from `OPEN`   | from `PAUSED` | from `COMPLETED`   |
| ---------- | ------------- | ------------- | ------------------ |
| `pause`    | → `PAUSED`    | 200 (no-op)   | **409** (terminal) |
| `resume`   | 200 (no-op)   | → `OPEN`      | **409** (terminal) |
| `complete` | → `COMPLETED` | → `COMPLETED` | 200 (no-op)        |

Author-only. Returns the **full refreshed post object** (same shape as
`GET /api/posts/:id`) so the client can re-render `requestState` + the resolved
`cta` in one round-trip. No notifications fire on any of these transitions.

While a post is `PAUSED` or `COMPLETED`, `POST /api/posts/:id/requests` (§15.4)
returns **409** — surface `cta.pausedLabel` / `cta.completedLabel` and disable
the pill (`cta.actionable === false`) so the viewer never taps into a 409.

| code | meaning                                            |
| ---- | -------------------------------------------------- |
| 400  | Post type has no request CTA (e.g. ANYTHING)       |
| 403  | Not the post author                                |
| 404  | Post not found (or private + viewer not approved)  |
| 409  | Post already COMPLETED — intake permanently closed |

---

## 16 — Save / unsave (private bookmark)

Private bookmark — viewer-only. The post author is never notified, and the
save count is never exposed to other viewers. Use this for the bookmark
icon and the "Saved" tab.

### 16.1 — Endpoints

```
PUT  /api/posts/:id/save        body: { saved: boolean }   idempotent
GET  /api/posts/saved           paginated list of viewer's saved posts
```

### 16.2 — Set save state

```http
PUT /api/posts/:id/save
Content-Type: application/json

{ "saved": true }       // or false to unsave
```

Idempotent. `{saved: true}` is a no-op if already saved; `{saved: false}`
is a no-op if not saved. The client sends the desired state directly, so
the server never has to reconstruct intent from arrival order.

Response:

```jsonc
{
  "success": true,
  "data": {
    "saved": true, // echoes the requested state
    "updatedAt": "2026-06-15T07:35:40.451Z",
  },
}
```

`isSaved` on every post object reflects the current viewer's save state.

### 16.3 — Optimistic UI (mandatory pattern — same as `/like`)

1. **On tap** — flip `isSaved` immediately in the client store. Record the
   new value as the desired state for this post.
2. **Fire** `PUT /api/posts/:id/save` with `{ saved: <desired> }` in the
   background. No `await` on the UI thread.
3. **On HTTP error** — roll back the local state and surface a toast.
4. **On feed refetch / detail screen open** — trust the server's
   `isSaved` as source of truth.

### 16.4 — Race conditions

Same rules as `/like` (§8.2):

- **Serialize per post** — keep at most one in-flight save mutation per
  postId. Cancel the previous before issuing a new one.
- **Track desired state + updatedAt** — store the user's latest intended
  `saved` value client-side. After each response, only apply it if its
  `updatedAt` is strictly newer than the last applied response for that
  post.
- **Idempotent retry on failure** — safe to re-send the same body.

Rate-limited: 30 calls per 10s per user.

### 16.5 — Saved tab list

```
GET /api/posts/saved?limit=20&cursor=<base64>
```

Returns the viewer's saved posts, **newest save first** (not newest post
first — a viewer may have saved an old post recently).

Response shape identical to other list endpoints (`items[]` + `nextCursor`).
Each item is normally a full post object including `isSaved: true` and the
standard `_count`.

#### Unavailable-post shells

A saved row never silently disappears. When the viewer can no longer
access the post (author flipped private, post deleted, author hid it),
the row is returned as a **shell** with only the post id and an
`unavailableReason`:

```jsonc
{
  "items": [
    {
      "id": "pst_abc",
      "isUnavailable": true,
      "unavailableReason": "RESTRICTED",
    },
    {
      /* full normal post object */
      "id": "pst_xyz",
      "isSaved": true,
      "isUnavailable": false, // (absent / falsy on visible posts)
      // ...
    },
  ],
  "nextCursor": "...",
}
```

`unavailableReason` enum:

| value              | cause                                                                    |
| ------------------ | ------------------------------------------------------------------------ |
| `RESTRICTED`       | Author flipped profile to private and viewer is not an approved follower |
| `DELETED`          | Post was hard-deleted (cascade)                                          |
| `HIDDEN_BY_AUTHOR` | `isHidden=true` (moderation / shadow-hide)                               |

Render shells as a greyed-out card: "This post is no longer available."
Tap → `PUT /api/posts/:id/save` with `{ saved: false }` to remove the
save.

Pass `nextCursor` back as `?cursor=...` to fetch the next page. Stop when
`null`. Cursor is opaque (base64url-encoded `(PostSave.createdAt, id)`).

### 16.6 — Mobile render rule

- Saved icon → `post.isSaved ? <BookmarkFilled/> : <BookmarkOutline/>`.
- "Saved" tab in profile → `GET /api/posts/saved` on mount + pull-to-
  refresh.
- Render shells as `{isUnavailable: true}` → greyed card with remove
  affordance.
- No count rendered anywhere — save count is private.
- No notification badge — save never triggers a notification.

### 16.7 — Errors

| code | meaning                                 |
| ---- | --------------------------------------- |
| 401  | Missing or invalid Authorization header |
| 404  | Post not found                          |
| 429  | Rate limit (30 calls / 10s per user)    |

---

## 17 — Repost (one-tap, v2)

A repost is a regular Post row with `type=REPOST`, `content=""` and
`originalPostId` set. Renders as "X reposted" + embedded original card. One
per (viewer, original), DB-enforced — re-tapping returns 409.

> **v2**: quote reposts ("with thoughts") were removed. The endpoint takes
> an **empty body**; any non-empty body is a 400. Historical quote rows
> (`content!=""`) may still appear — render their content above the
> embedded original. See `mobile-post-interactions-guide.md` §2 for the
> full v2 contract including `repostedBy`.

The kind catalog (`GET /api/posts/types`) **does not include** REPOST —
mobile picker never offers it. Reposts are created exclusively via
`POST /api/posts/:id/repost`.

### 17.1 — Endpoints

```
POST   /api/posts/:id/repost          body: {} → new post object
DELETE /api/posts/:id/repost          undo repost
GET    /api/posts/:id/reposts         repost list
```

### 17.2 — Repost a post

```http
POST /api/posts/:id/repost
{}                          // body MUST be empty
```

Response: full post object (same shape as `POST /api/posts`) with
`type=REPOST`, `originalPostId` resolved to root, and the embedded
`original` summary. Server collapses repost-of-a-repost chains — the new
row always points at the root original, never another repost.

Errors:

| code | reason                                   |
| ---- | ---------------------------------------- |
| 400  | self-repost (can't repost your own post) |
| 400  | original is hidden                       |
| 400  | non-empty body (quotes not supported)    |
| 404  | original not found                       |
| 409  | already reposted (treat as success)      |
| 429  | rate limit (30 reposts / 10 min)         |

### 17.3 — Post-object additions (every list + detail endpoint)

```jsonc
{
  // ...existing fields...
  "originalPostId": "pst_root123",     // null on non-reposts
  "original": {                         // embedded; null when original was deleted
    "id": "pst_root123",
    "type": "FIND_TEAMMATES",
    "title": "Looking for a 4th for hackathon",
    "content": "Building a flashcard app...",
    "createdAt": "2026-06-10T12:00:00Z",
    "author": { "id": "usr_a", "username": "alice", "profile": { ... } },
    "media": [ /* same shape as top-level post.media */ ]
  },
  "isReposted": true,                   // viewer has reposted this root
  "viewerRepostId": "pst_myrepost1",    // viewer's own repost row id
  "repostedBy": {                       // mutual-follow attribution; null when none
    "users": [ { "id": "usr_c", "username": "charlie", "profile": { ... } } ],
    "mutualCount": 5                    // total MUTUAL reposters; public total = _count.reposts
  },
  "_count": { "likes": 3, "comments": 5, "requests": 0, "reposts": 12 }
}
```

`repostedBy` names only the viewer's mutuals (both follow each other),
capped at 3 newest — "Reposted by Charlie, Dana and 3 others". Keyed on the
root post, so original and repost cards agree.

Render rule:

> **Feed never contains `type=REPOST` cards** — reposts boost the original
> into feeds and `repostedBy` credits them. REPOST cards appear only on
> profile lists and notification deep-links. Always interact via `post.id`.

- `type === "REPOST"` + `originalPostId !== null` →
  show "X reposted" hat at top of card. Legacy quote rows: render `content`
  above the embedded `original` card (empty content = no body, just the
  original card).
- `type === "REPOST"` + `originalPostId === null` →
  original was deleted. Render placeholder ("Original removed").
- `type !== "REPOST"` → standard render. `_count.reposts` is the number of
  times _this_ post has been reposted; `isReposted` tells you if the viewer
  is one of them.

Like + comment buttons on a repost card act on the **repost row** (not the
original). Tap on the embedded original card → navigate to that post's
detail screen.

### 17.4 — Undo a repost

```http
DELETE /api/posts/:id/repost
```

`:id` can be the original or any post in the same repost chain — server
resolves to root. Returns 409 if the viewer has no repost row.

### 17.5 — Repost list

```http
GET /api/posts/:id/reposts?limit=20&cursor=<base64>
```

Returns reposts of the root original, newest first (`kind:"quote"` = legacy
rows only).

```jsonc
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "pst_repost1",
        "kind": "simple",                   // "simple" | "quote"
        "createdAt": "2026-06-15T07:00:00Z",
        "user": { "id": "usr_b", "username": "bob", "profile": { ... } },
        "contentPreview": ""                // empty for simple
      },
      {
        "id": "pst_repost2",
        "kind": "quote",
        "createdAt": "2026-06-15T06:30:00Z",
        "user": { ... },
        "contentPreview": "Loved this — exactly what I've been saying..."
      }
    ],
    "nextCursor": "eyJj..."
  }
}
```

### 17.6 — Notifications

| type           | recipient        | trigger                  |
| -------------- | ---------------- | ------------------------ |
| `repost`       | root post author | viewer simple-reposts    |
| `quote_repost` | root post author | viewer quote-reposts     |
| `mention`      | mentioned user   | quote-repost contains @x |

Payload `data.entityId` → the **repost** id (tap-through lands on the
repost card, not the original). `data.originalPostId` carries the root
id when the client wants to navigate to the original instead.

### 17.7 — Mobile UX summary

```
1. User taps repost icon → bottom sheet: "Repost" | "Quote".
2A. SIMPLE  → POST /api/posts/:id/repost {}.
    Mobile: flip isReposted=true on the source card, bump _count.reposts.
2B. QUOTE   → open compose w/ embedded original.
    POST /api/posts/:id/repost { content: "my take" }.
    Mobile: same flag flip + push the new repost into the user's profile feed.
3. Feed render: type=REPOST → "X reposted" hat + embedded original.
4. Undo simple → DELETE /api/posts/:id/repost.
   Undo quote  → DELETE /api/posts/:repostId (standard post delete).
```

Optimistic UI optional but recommended — server response is fast enough to
skip if you'd rather just await.

---

## 18 — Share (external / URL)

Share generates a tracked URL the viewer can paste anywhere (WhatsApp,
Messages, copy-paste). Backend tracks each share for analytics + future
attribution.

> **Status (2026-09-09)**: the URL is live and served —
> `https://letsclustr.com/posts/<id>?ref=<code>`. Opening it launches the app
> once the app claims the domain, and otherwise renders a download page.
> Everything about configuring that on each platform is in
> [mobile-deep-links-guide.md](../deep-links/mobile-deep-links-guide.md);
> this section is only about getting the URL.

### 18.1 — Endpoints

```
POST /api/posts/:id/share              body: { channel? } → { url, shareCode, channel }
GET  /api/posts/:id/share/preview      → { title, description, imageUrl }
```

### 18.2 — Record a share + get URL

```http
POST /api/posts/:id/share
Content-Type: application/json

{ "channel": "NATIVE" }
```

`channel` is one of:
`WHATSAPP | TWITTER | INSTAGRAM | FACEBOOK | COPY_LINK | CHAT | NATIVE | OTHER`.

Default `NATIVE`. Use NATIVE when invoking the OS native share sheet —
the OS hides which target the user actually picked, so you can't
attribute more precisely. Use specific channels only when the user picks
an in-app target you know about (e.g. CHAT for in-app conversation
share, COPY_LINK for copy-button).

Response:

```jsonc
{
  "success": true,
  "data": {
    "url": "https://letsclustr.com/posts/019f45c6-…?ref=Ab12CdEf34",
    "shareCode": "Ab12CdEf34",
    "channel": "NATIVE",
  },
}
```

Errors:

| code | reason                                |
| ---- | ------------------------------------- |
| 404  | post not found or hidden              |
| 429  | rate limit (60 calls / minute / user) |

### 18.3 — Mobile flow

```
1. User taps share icon on a post.
2. Mobile: POST /api/posts/:id/share { channel: "NATIVE" }.
3. Server returns { url, shareCode }.
4. Mobile passes url to iOS UIActivityViewController / Android
   Intent.ACTION_SEND.
5. User picks WhatsApp / Messages / Copy / etc. URL goes out.
6. Recipient taps the link:
   - app installed + domain verified → OS routes the URL into the app;
     parse the postId and open the detail screen.
   - app not installed → browser opens the fallback page with
     App Store / Play Store buttons.
```

### 18.4 — Share-sheet preview prefill

```http
GET /api/posts/:id/share/preview
```

Returns the OG-meta values mobile can prefill in the OS share sheet so
the recipient sees a meaningful link preview even before the link is
fetched.

```jsonc
{
  "success": true,
  "data": {
    "title": "Looking for a 4th for hackathon team", // or fallback
    "description": "Building a flashcard app. Need a designer or another dev...",
    "imageUrl": "https://storage.googleapis.com/…/abc.jpg", // first image, or null
  },
}
```

Title falls back to `<username> on Clustr` for ANYTHING-kind posts that have
no title. These values are for the **OS share sheet only** — the web page the
recipient lands on shows branded metadata and never the post itself.

### 18.5 — Share count on post

Every post object now carries `_count.shares`. Render alongside likes /
comments / reposts:

```jsonc
"_count": { "likes": 3, "comments": 5, "requests": 0, "reposts": 12, "shares": 7 }
```

No per-viewer `isShared` flag — share is a transient action, not a
persisted viewer state.

### 18.6 — In-app chat share (cleaner UX path)

Corrected: sending a post into a chat is **not** a URL share. There is a native
`MessageType.POST_SHARE` — send `{ postId }` to
`POST /api/chat/conversations/:id/messages` and the card is resolved per-viewer
on read. See [mobile-post-share-guide.md](mobile-post-share-guide.md). A
`PostShare` row with `channel: CHAT` is still written, so it counts here too.
The URL flow in this section is for **external** channels only.

### 18.7 — What is still outstanding

| Step | Status |
| --- | --- |
| Domain + TLS (`letsclustr.com`) | done — Firebase Hosting |
| AASA + `assetlinks.json` | served, carrying placeholder identifiers |
| Fallback page | done |
| Mobile config (Associated Domains / intent-filter) | **yours** — [deep-links guide §3](../deep-links/mobile-deep-links-guide.md) |
| Identifiers (team id, bundle, package, fingerprints) | **yours** — [deep-links guide §8](../deep-links/mobile-deep-links-guide.md) |
| Click attribution (`ShareClick`) | deferred, nothing reads `ref` |

Until the identifiers land, a tapped link opens the browser fallback page rather
than the app. That is unconfigured, not broken.
