# Mobile Post Requests (CTA) — Integration Guide

> The CTA flow on a post: a viewer taps the post's call-to-action, picks
> prompt options + writes a quick note, and submits a **request**. The post
> author reviews an inbox and accepts / declines. Backend already shipped.
> Companion to `mobile-posts-guide.md` — read that first for the post-object
> shape, post types, and auth headers. The **DM integration** (requests in
> the DM tab, accept opening a chat, "new" badges) is wired separately in
> `mobile-post-request-dm-guide.md`.

- Base URL: `http://localhost:3001` (dev) — production URL from backend team
- Auth: `Authorization: Bearer <accessToken>` on every endpoint
- Headers: `Content-Type: application/json`, `X-Client-Type: mobile`

---

## Concept — one flow, label varies per post type

There is **one** request mechanism. Only the button copy and the verb in
notifications change per post type. Three post types enable the CTA:

| Post type (`type`) | `cta.buttonLabel` | verb |
|---|---|---|
| `FIND_TEAMMATES` | `Request to Join` | join |
| `INVITE_PEOPLE` | `Request to Attend` | attend |
| `START_SOMETHING` | `Request to Collaborate` | collaborate |

`SHARE_OPPORTUNITY`, `ANYTHING`, `REPOST` have **no** CTA (`cta.enabled = false`).

> **Do not hardcode button labels.** Read them off the post object's `cta`
> block (served per-post). The viewer-facing label swaps with the viewer's
> own request state: `buttonLabel` (none yet) → `pendingLabel` ("Request
> Sent") → `acceptedLabel` ("Open chat") → `rejectedLabel` ("Request
> Declined"). The author always sees `authorLabel` ("View Responses").

> **"Open chat" navigation.** When `viewerRequestStatus` is `ACCEPTED`, the
> post object also carries `viewerRequestConversationId` (string | null) —
> navigate to `ksn://chat/{viewerRequestConversationId}` on tap. Null while
> ACCEPTED is a valid state (blocked pair / linkage failure) → keep the
> label but open the post's request detail instead. Null for every other
> status.

The request payload is **identical** across all three types: a subset of the
post's `requestOptions` plus an optional free-text note. You never branch on
post type in the request code — the server validates against that post's own
`requestOptions`.

---

## At-a-glance endpoint table

| Method | Path | Who | Use |
|---|---|---|---|
| `POST` | `/api/posts/:id/requests` | viewer | submit / re-submit a request |
| `DELETE` | `/api/posts/:id/requests/me` | viewer | withdraw own pending request |
| `GET` | `/api/posts/requests/mine` | viewer | "My Requests" tab (outgoing, all posts) |
| `GET` | `/api/posts/:id/requests` | author | inbox for one post (searchable by requester via `q`) |
| `GET` | `/api/posts/requests/inbox` | author | aggregate inbox (all my posts; searchable by post title via `q`) |
| `PATCH` | `/api/posts/:id/requests/:reqId` | author | accept / reject a pending request |

---

## PostRequest object

Returned by every endpoint below (inside `data`, or `data.items[]` for lists).

```jsonc
{
  "id": "preq_abc123",
  "postId": "pst_xyz789",
  "requesterId": "usr_aaa",
  "status": "PENDING",                  // PENDING | ACCEPTED | REJECTED | WITHDRAWN
  "selectedOptions": ["Frontend", "Design"],
  "message": "I've shipped two React Native apps.",   // null if omitted, ≤500 chars
  "details": null,                      // optional free-form object, usually null
  "respondedAt": null,                  // ISO time when author decided; null while PENDING
  "respondedBy": null,                  // author userId once decided; null while PENDING
  "conversationId": null,               // DM opened on accept — see section 6. ⚠ ACCEPTED
                                        // with null conversationId is a VALID state
  "authorReadAt": null,                 // author-side read marker — see "New badge" note
                                        // in sections 4/5; meaningless to the requester
  "createdAt": "2026-06-30T10:00:00Z",
  "updatedAt": "2026-06-30T10:00:00Z",
  "requester": {                        // always included
    "id": "usr_aaa",
    "username": "alice",
    "profile": { "displayName": "Alice", "avatarUrl": "https://..." }  // profile may be null
  },
  "post": { /* post summary — only on /requests/mine and /requests/inbox */ }
}
```

`post` summary shape (only on the two cross-post list endpoints):

```jsonc
{
  "id": "pst_xyz789",
  "type": "FIND_TEAMMATES",
  "title": "Looking for a 4th",
  "content": "Hackathon team forming...",
  "authorId": "usr_bbb",
  "eventAt": null,
  "createdAt": "2026-06-29T09:00:00Z"
}
```

---

## 1 — Submit a request (viewer)

`POST /api/posts/:id/requests`

The CTA action. Creates the request, or **updates the viewer's existing row in
place** — there is exactly one request per (post, viewer). Idempotent: tapping
twice does not create duplicates.

### Request body

```jsonc
{
  "selectedOptions": ["Frontend", "Design"],   // optional; must be ⊆ post.requestOptions
  "message": "I've shipped two React Native apps.",  // optional; ≤500 chars
  "details": { "portfolio": "https://..." }     // optional; free-form object, usually omit
}
```

All three fields optional — `{}` is a valid submit (note + options are "Add a
quick note (Optional)"). Validate `selectedOptions` client-side against the
post's `requestOptions` before sending; server rejects unknown options with 400.

### Response `200`

The `PostRequest` object, `status: "PENDING"`.

```jsonc
{ "success": true, "data": { /* PostRequest, status PENDING */ } }
```

### Re-submit semantics

| Current state | POST result |
|---|---|
| no row yet | creates `PENDING`, notifies author |
| `WITHDRAWN` | flips back to `PENDING`, re-notifies author |
| `PENDING` | refreshes `selectedOptions` / `message`, **no** re-notify |
| `ACCEPTED` | **409** — already accepted |
| `REJECTED` | **409** — declined is final, cannot re-submit |

> **Reject is sticky.** Once an author declines, the requester cannot
> re-request. Surface `rejectedLabel` ("Request Declined") and disable the CTA.

### Errors

| Code | When | UI |
|---|---|---|
| `400` | post type has no CTA / option not in `requestOptions` / requesting your own post | block before send; shouldn't reach if CTA hidden on own posts |
| `404` | post not found, or not visible to viewer | treat as gone |
| `409` | already `ACCEPTED` or `REJECTED` | refresh post, show accepted/declined label |
| `429` | rate limit — 100 submits per viewer / rolling 24h (across all posts) | toast "Try again later" |

---

## 2 — Withdraw own request (viewer)

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

Soft-cancel. Sets the viewer's row to `WITHDRAWN`. No body.

### Response `200`

```jsonc
{ "success": true, "data": { /* PostRequest, status WITHDRAWN */ } }
```

A withdrawn request **can** be re-submitted later (back to section 1).

### Errors

| Code | When |
|---|---|
| `404` | viewer has no request on this post |
| `409` | request is not `PENDING` (already accepted/rejected/withdrawn) — only pending can be withdrawn |

---

## 3 — My outgoing requests (viewer)

`GET /api/posts/requests/mine`

Drives a "My Requests" tab — every request this viewer has sent, across all
posts. Each item carries the `post` summary so you can render context.

### Query params

| Param | Default | Notes |
|---|---|---|
| `status` | (all) | `PENDING` \| `ACCEPTED` \| `REJECTED` \| `WITHDRAWN` |
| `limit` | `20` | 1–50 |
| `cursor` | — | from previous `nextCursor` |

### Response `200`

```jsonc
{
  "success": true,
  "data": {
    "items": [ /* PostRequest[], each with embedded `post` */ ],
    "nextCursor": "eyJjcmVhdGVkQXQiOi..."   // null when no more pages
  }
}
```

---

## 4 — Author inbox for one post

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

Author-only. The "View Responses" screen for a single post. Cursor-paginated,
with a `counts` object for tab badges.

### Query params

Section 3's `status` / `limit` / `cursor`, **plus** `q` — searches this post's
requests by **requester** username / displayName (case-insensitive).

| Param | Notes |
|---|---|
| `q` | Optional. Trimmed; applied only when **≥ 2 chars** (shorter/empty ignored — full list, no error). Narrows `items` **only** — `counts` / `unread` stay whole-post totals, so the status-tab badges don't move while you search. |

Back a search box on the responses screen with it (debounce ~250ms).

### Response `200`

```jsonc
{
  "success": true,
  "data": {
    "items": [ /* PostRequest[], each with `requester` */ ],
    "nextCursor": null,
    "counts": { "pending": 3, "accepted": 1, "rejected": 0, "withdrawn": 2, "unread": 2 }
  }
}
```

`counts` is always totals across **all** statuses for the post, regardless of
the `status` filter — use it for tab/badge numbers.

### New badge — fetch marks read

`counts.unread` = rows the author has **never been served** by an inbox
endpoint (any status). Fetching this endpoint marks exactly the returned rows
as read server-side — there is **no explicit mark-read call**. The payload you
receive still carries the *pre-mark* value, so:

- `authorReadAt == null` in the fetched items → highlight the row as **new**.
- The next fetch returns those rows with `authorReadAt` set → no highlight.
- Unfetched pages stay unread (pagination-correct) — `unread` only drops as
  pages are actually delivered.

### Errors

| Code | When |
|---|---|
| `403` | viewer is not the post author |
| `404` | post not found |

---

## 5 — Aggregate inbox (all my posts)

`GET /api/posts/requests/inbox`

Author-only. One feed of incoming requests across **every** post the author
created — drives a home-screen "Requests" tab. Each item carries both
`requester` and `post`.

### Query params

Section 3's `status` / `limit` / `cursor`, **plus** `q`. **Difference:** when
`status` is omitted it defaults to `PENDING` (the actionable set), not all.

| Param | Notes |
|---|---|
| `q` | Optional — searches by **post title** (case-insensitive). Trimmed; applied only when **≥ 2 chars** (shorter/empty ignored). Narrows `items` **only** — `counts` / `unread` stay whole-inbox totals. Titleless posts (title is optional) never match a title search. |

Use it to let an author jump to requests for one specific post when the
aggregate inbox spans many posts.

### Response `200`

Same shape as section 4 (`items` + `nextCursor` + `counts`). `counts` totals
span all the author's posts. Fetch-marks-read applies here too (see section
4's "New badge" note) — `counts.unread` spans all the author's posts.

---

## 6 — Accept / reject / reopen a request (author)

`PATCH /api/posts/:id/requests/:reqId`

Author-only. `accept` / `reject` act on `PENDING` rows; `reopen` acts on
`ACCEPTED` rows.

### Request body

```jsonc
{ "action": "accept" }   // or "reject" or "reopen"
```

### Response `200`

```jsonc
{ "success": true, "data": { /* PostRequest, status ACCEPTED or REJECTED, respondedAt/By set,
                               conversationId set on accept (see below) */ } }
```

### Accept opens the DM

Accepting **flows the request into chat**:

1. The server find-or-creates the direct DM between author and requester
   (created with `status: "ACCEPTED"` — the author's accept is the consent,
   no chat-request tray step). If a PENDING chat request already existed
   between the pair, it flips to `ACCEPTED`.
2. The DM is seeded with **one `POST_SHARE` message from the requester**:
   the post as a live card (`sharedPostId`) + the request's note as
   `content`. Render it exactly like any shared post — see
   `mobile-post-share-guide.md`. Accepting more requests from the same
   person (other posts) appends more seed messages into the **same** DM.
3. `conversationId` is written on the PostRequest row, returned in this
   response, and included in the `post_request_accepted` notification.
   Navigate straight to `/chat/:conversationId`.

> ⚠ **`ACCEPTED` with `conversationId: null` is a VALID state.** Chat linkage
> is a side-effect — it is skipped when the pair has a block, and can fail
> independently of the accept. Never assume ACCEPTED ⇒ conversation exists;
> when `conversationId` is null, fall back to linking to the post.

If the post is later **deleted**, the seed message survives but its
`sharedPostId` becomes `null` (hard delete + SetNull) → render the standard
"post unavailable" placeholder, exactly like any other POST_SHARE whose post
is gone (see `mobile-post-share-guide.md`). A post the viewer merely *loses
access to* resolves live as `available: false` instead.

Rejecting has no chat side-effect and is final (reject is sticky).

### Reopen — author re-reviews an accepted request

`{ "action": "reopen" }` flips an **ACCEPTED** row back to **PENDING**:

- `respondedAt` / `respondedBy` reset to null; **`conversationId` becomes
  null** on the row. The conversation itself is untouched — chat history
  (including the seed message) stays.
- The requester gets a `post_request_reopened` notification; their CTA
  reverts to `pendingLabel` ("Request Sent") and the request row returns to
  their DM tab.
- A later re-accept relinks the **same** conversation and does **not**
  duplicate the seed message.
- Only ACCEPTED can be reopened — reopening a PENDING/REJECTED/WITHDRAWN row
  is 409. Rejected stays final.

### Errors

| Code | When |
|---|---|
| `403` | viewer is not the post author |
| `404` | post or request not found, or `reqId` doesn't belong to `:id` |
| `409` | accept/reject on a non-`PENDING` row, or reopen on a non-`ACCEPTED` row — refresh the inbox |

---

## Notifications

Both sides get push/in-app notifications (handled by the notifications system,
see `mobile-posts-guide.md` for the notification envelope):

- **Author** on new/re-opened request → type `post_request`, title
  *"{name} wants to {verb} your post"*, `data.webPath = /posts/:id/requests`.
- **Requester** on decision → type `post_request_accepted` /
  `post_request_rejected`. On **accept with a linked DM**, `data` carries
  `conversationId` and deep-links to the chat
  (`ksn://chat/:conversationId`); otherwise (reject, or accept without a DM)
  it falls back to the post (`data.webPath = /posts/:id`).
- **Requester** on reopen → type `post_request_reopened`, title *"Your
  request to {verb} is being reviewed again"*, links to the post.

`data` carries `requestId`, `postId` (`entityId`), `postType`, `actorId` — use
to deep-link straight to the relevant inbox row, chat, or post.

---

## DM tab wiring (requester side)

Product decision: the requester sees their pending requests **inside the DM
tab**, as rows above/alongside real conversations. This is a **client-side
merge** — there is no combined endpoint:

1. Fetch `GET /api/posts/requests/mine?status=PENDING` (section 3) and render
   each row in the DM list: post title + "Waiting for author…" state. Tapping
   it can open the post or a request detail sheet.
2. Real conversations keep coming from `GET /api/chat/conversations`
   (see `mobile-chat-guide.md`).
3. On `post_request_accepted` (push or refetch), the row becomes a real
   conversation — navigate via `conversationId` and drop the request row
   (the DM now contains the seeded POST_SHARE opener).
4. Withdrawn/rejected rows leave the DM tab (they remain visible in a "My
   Requests" history screen if you build one — section 3 without the status
   filter).

Remember the valid-state caveat from section 6: an ACCEPTED row with
`conversationId: null` cannot navigate to chat — link to the post instead.

---

## Wiring checklist

1. Render the CTA button from the post's `cta` block; swap label by the
   viewer's request state (`pendingLabel` / `acceptedLabel` / `rejectedLabel`).
2. Hide the CTA on the viewer's own posts; show "View Responses" (`authorLabel`)
   instead, linking to section 4.
3. CTA tap → option sheet (post's `requestPrompt` + `requestOptions`) + note →
   `POST` (section 1). On 409, refresh the post to pick up the real state.
4. "My Requests" tab → section 3. "Requests" inbox tab → section 5; per-post
   "View Responses" → section 4. Both author-inbox screens take a `q` search
   box (section 5 by post title, section 4 by requester); ≥ 2 chars, debounce
   ~250ms, and remember `counts` stay whole-inbox totals while filtering.
5. Author accept/decline → section 6, then optimistically move the row between
   `counts` buckets.
6. Allow withdraw (section 2) only while `status === "PENDING"`.
