# Mobile Post Request ↔ DM Wiring — Integration Guide

> Wiring guide for the **request→DM integration only**: a request opening its
> conversation at submit time, accept flipping it, the author's "new response"
> badge, and the edit freeze. Prerequisites (read first):
> `mobile-post-requests-guide.md` (request endpoints + CTA),
> `mobile-chat-guide.md` (conversations + sockets),
> `mobile-post-share-guide.md` (POST_SHARE card rendering).

- 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`

## 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-09** — The conversation's `pendingPostRequest` now includes `selectedOptions`, so the chat card can show the ticked options exactly like the author's Responses tray. No extra call.
- **2026-09-01** — Request system messages now name the post — "Your request for "Debate Partner" was accepted." (section 7). The title is snapshotted, so it stays readable even if the post changes later.
- **2026-09-01** — The status chip on the request card is withdrawn. Build the card plus the thread's own lines; there is no `postRequest: { id, status }` field to render.
- **2026-09-01** — `post_request_opened` is gone — submitting a request now writes only the seed message. A rejected request reads "wasn't accepted".
- **2026-08-31** — Chat design-review decisions were recorded ahead of implementation — check the "decided, not yet shipped" note before building.

---

## What changed (delta over the base requests guide)

| Surface | New |
|---|---|
| **A request opens its DM at SUBMIT, not on accept** | the requester's outgoing request is an ordinary row in `folder=inbox`. **The client-side merge and the fake-conversation screen are gone** (§1) |
| Conversation object (list, `:id`, search) | `pendingPostRequest` (`{ id, postId, selectedOptions }` \| null) — **provenance, not a lock**: set while the request is unanswered, so you can banner "Awaiting a response to your post" and open the post from the card |
| Sending into an unanswered request | the ordinary PENDING-DM rule: the **requester** sends text (media / GIF / post card → 403), the **author** cannot send until they accept. **Changed 2026-08-31** — the old `403 "Waiting for the author to accept your request"` that refused *both* sides is gone |
| Where the author sees it | **Responses** while the thread is only the request; once the requester writes a message of their own it *also* appears in their chat **Requests** tray with the whole history and the usual Accept / Delete / Block |
| `PostRequest` object | `conversationId` (string \| null, **set from submit**), `authorReadAt` (ISO \| null), `submitCount` (int) |
| Author inbox `counts` | `unread` number |
| `POST /api/posts/:id/requests` submit | opens/attaches the DM, returns `conversationId`. The note is moderated — `422 MESSAGE_BLOCKED` on blocked words |
| `PATCH .../requests/:reqId` accept | flips the existing DM to ACCEPTED and backfills receipts; never writes a second seed message |
| `post_request_accepted` notification | `data.conversationId` + chat deep link |
| Post object (list + detail) | `viewerRequestConversationId` (string \| null) — "Open chat" CTA target when the viewer's request is ACCEPTED |
| `PATCH .../requests/:reqId` | new `"reopen"` action — ACCEPTED back to PENDING (+ `post_request_reopened` notification). **The conversation stays ACCEPTED and keeps its `conversationId`** — reopening a request never closes an open chat |
| `PATCH /api/posts/:id` | 409 once `eventAt` has passed |

---

## 1 — Requester's DM tab (nothing to merge)

> **This section replaced a client-side merge and a "fake conversation"
> rendering contract. Both are gone — delete that code.** A post request is a
> real `Conversation` with a real `Message` from the moment it is submitted, so
> there is nothing to splice into the chat list and nothing to fake.

The DM tab is one call:

```http
GET /api/chat/conversations?folder=inbox
```

A pending outgoing request comes back as an ordinary row — real
`conversationId`, real message, real `sequenceNumber`, receipts, sockets,
cursor pagination, and `updatedAt` ordering that already puts it in the right
place among the user's other chats. No second fetch, no merge, no client sort.

It is marked so you can render its state:

```jsonc
{
  "id": "conv_abc",
  "status": "PENDING",
  "requesterId": "<me>",
  "pendingPostRequest": { "id": "req_x", "postId": "post_y", "selectedOptions": ["Frontend"] },
  "messages": [ { "type": "POST_SHARE", "sharedPostId": "post_y", "content": "…the note…" } ]
}
```

| `pendingPostRequest` | Render |
|---|---|
| non-null | Banner: "Awaiting a response to your post". The `POST_SHARE` card is the thread's first message; tapping it opens the post, and `selectedOptions` are the options the requester ticked (`[]` when none) — render them as chips beside the note. **Do not disable the composer on this field** |
| null | no banner — an ordinary chat |

The composer follows the same rule as every other conversation, from `status` +
`requesterId` alone:

```ts
if (conv.status === "ACCEPTED") full();
else if (conv.requesterId === me) textOnly();   // ← a post requester lands here
else locked();                                  // Accept / Delete / Block
```

So the requester may keep writing text into their own request before the author
answers — `media[]` and `postId` stay `403` until it is accepted, exactly as in a
cold message request.

Opening the thread is a normal chat screen against the normal endpoints
(`GET /api/chat/conversations/:id/messages`). The request note and the post card
are already in the timeline as message #1, sent by the requester.

State changes arrive as they do for any chat:

| Event | Do |
|---|---|
| `conversation_status_changed` → `ACCEPTED` | the author accepted — full composer; `pendingPostRequest` is null on the next fetch |
| `chat_list_update` with `pendingPostRequest: null` | the request was answered on the author's side — drop the banner. No refetch: the event carries `pendingPostRequest` and `tray` |
| `conversation_cleared` | the request was declined or withdrawn — drop the row. Nothing was deleted; a later message brings the thread back |
| `chat_list_update` | ordinary list refresh |

There is still no socket event for `PostRequest` state itself — request status
lives on the post CTA and in `/requests/mine`; the chat side only ever tells you
about the conversation.

**If the pair already had a chat**, the request appends to that thread instead
of opening a new one, `pendingPostRequest` is **not** set (an open conversation
stays open), and it is an ordinary message in an ordinary thread — full composer
on both sides. One user-to-user relationship has exactly one conversation.

**Accepting in the chat tray is not answering the request.** If the author taps
Accept on the row in their Requests tray, the conversation flips to `ACCEPTED`
and the chat opens — but the `PostRequest` stays `PENDING` and still needs an
Accept or Ignore in Responses. Two surfaces, two decisions.

## 2 — Accept → conversation handoff

The conversation already exists — the requester has had it in their inbox since
they submitted. Accept flips it from `PENDING` to `ACCEPTED`, backfills the
author's receipt for the seed message, and opens the author's composer (the
requester's was never closed). **No second
conversation and no second seed message are ever created.**

The requester's next fetch (or the push payload) carries the same chat:

```jsonc
// PATCH /api/posts/:id/requests/:reqId  → 200 (author side)
// GET  /api/posts/requests/mine         → items[] (requester side)
{
  "status": "ACCEPTED",
  "conversationId": "conv_abc123",   // navigate: /chat/conv_abc123
  ...
}
```

The DM opens with a **seed message already inside**: a normal `POST_SHARE`
message, `senderId` = the requester, `content` = their request note,
`sharedPostId` = the post. Render it exactly like any shared post
(`mobile-post-share-guide.md`) — no special casing. Accepting more requests
from the same person appends more seed messages into the **same** DM.

> ⚠ **`ACCEPTED` with `conversationId: null` is a VALID state** (blocked
> pair, or linkage failed server-side). Never assume ACCEPTED ⇒ chat exists.
> Null → link to the post instead of the chat. Do not retry client-side; the
> backend has an operational repair path.

If the post is later deleted, the seed message survives with
`sharedPostId: null` → render the standard "post unavailable" placeholder.

## 3 — Socket events you will receive

All existing events from `mobile-chat-guide.md` — the seed message reuses the
normal send pipeline. On the accept moment:

| Event | Room | When |
|---|---|---|
| `conversation_status_changed` `{ conversationId, status: "ACCEPTED" }` | both users | only if a PENDING chat request already existed between the pair (it flips) |
| `new_message` (full message, `type: "POST_SHARE"`) | conversation room | the seed. Resolve the card via `POST /api/posts/share-cards` as usual |
| `chat_list_update` `{ conversationId, status: "ACCEPTED", lastMessage, unreadIncrement }` | both personal rooms | sidebar row. `unreadIncrement: 1` for the author, `0` for the requester (they're the sender) |

No `message` / `message_request` push fires for the seed — the
`post_request_accepted` push is the single notification and it deep-links to
`ksn://chat/:conversationId` (falls back to `ksn://posts/:id` when
`conversationId` is null).

## 4 — Author's responses screen: "new" badge

Both author inboxes (`GET /api/posts/:id/requests`,
`GET /api/posts/requests/inbox`) now return:

```jsonc
"counts": { "pending": 3, "accepted": 1, "rejected": 0, "withdrawn": 2, "unread": 2 }
```

**Contract — fetch marks read.** There is no mark-read endpoint and no
viewport tracking:

- A row with `authorReadAt: null` **in the payload you just received** is
  "new" → highlight it. The server marks exactly those rows read as it
  serves them.
- Your *next* fetch returns them with `authorReadAt` set → no highlight.
  Keep the highlight for the lifetime of the screen from the payload you
  rendered — do not refetch to "confirm".
- `counts.unread` is computed **before** the same response's marking, so the
  badge you display alongside page 1 is accurate at fetch time.
- Pagination: only served rows get marked. Unfetched pages still count in
  `unread` until actually delivered.
- `unread` spans **all statuses** (a withdrawn-before-seen row is still
  "new"). Requester endpoints never mark anything.

Badge suggestion: use `counts.unread` from the aggregate inbox for the
home-screen "Requests" tab badge; per-post `counts.unread` for each "View
Responses" entry point.

## 5 — Post edit freeze (author UX)

`PATCH /api/posts/:id` now returns **409**
(`"Post can no longer be edited after its event date"`) once the post's
`eventAt` is in the past. Wiring:

- Hide/disable the edit action when `post.eventAt < now`.
- Posts without `eventAt` never freeze.
- This is independent of the existing prompt freeze (`requestPrompt*` locked
  after the first request — also 409). Handle both by surfacing the server
  message.

## 6 — Edge matrix

| Case | What the client sees | Do |
|---|---|---|
| submit, blocked pair | 200, `conversationId: null` | request stands with no chat; accept will retry the linkage |
| submit while the author has an unanswered message request to you | 200, `conversationId: null` | the request cannot attach to a thread you have not accepted; accept creates the DM instead |
| accept, blocked pair | 200, `conversationId: null` | author: normal accepted row, no chat affordance. requester: ACCEPTED row linking to post |
| decline / withdraw, seed-only thread | row drops from the DM tab via `conversation_cleared` | nothing is deleted — a later message from either side brings the thread back |
| decline / withdraw after the requester has chatted | **no** `conversation_cleared` — the thread survives on both sides; a `post_request_declined` / `_withdrawn` system message lands in it | refetch the row: `pendingPostRequest` is now null, so drop the banner. For the author it stays an ordinary message request in Requests |
| requester messages before the author answers | author's Requests tray gains the row (with the seed card + the message); requester's own row is unchanged in their inbox | the author's `message_request` notification fires, and their `chat_list_update` carries `tray: "requests"` + `pendingPostRequest` — place and render it without a fetch |
| accept twice / stale inbox | 409 "Request is not pending" | refetch inbox |
| post deleted after accept | seed message with `sharedPostId: null`; requester's `/requests/mine` row gone (cascade) | placeholder card; DM survives |
| conversation deleted later | `conversationId` back to null on the row | fall back to post link |
| same requester on 2 posts | same `conversationId` on both rows | one DM, two seed messages |
| withdraw then re-submit | a **new** seed message, higher `sequenceNumber` | the row reappears in the DM tab showing only what arrived after the withdrawal. Two seeds are still not a conversation — the author sees it in Responses only, until the requester writes something of their own |
| author reopens an accepted request | row back to `PENDING`, **`conversationId` kept**; the conversation stays `ACCEPTED` | chat is unaffected and stays usable — only the post CTA reverts to `pendingLabel` |
| request while intake paused/completed | 409 on submit | per base guide — CTA state from the post object |

## 7 — Wiring checklist

1. DM tab = `GET /api/chat/conversations?folder=inbox`, nothing else. Render
   `pendingPostRequest` rows as "Waiting for acceptance" with the composer
   disabled (§1). **Delete any merge and fake-conversation code.**
2. On `post_request_accepted`: drop the request row, navigate via
   `data.conversationId`; null → post fallback (§2).
3. Render the seed like any POST_SHARE message — zero new message UI (§2).
4. Handle the three accept-moment socket events (§3) — all pre-existing
   handlers from the chat guide.
5. Responses screens: highlight `authorReadAt: null` rows from the fetched
   payload; badges from `counts.unread` (§4).
6. Disable post editing when `eventAt` has passed; surface 409 messages (§5).


---

## 7 — Decisions from the 2026-08-31 design review

Four touch these screens. **Two shipped on 2026-09-01** (marked ✅); the other
two are decided and not built. Full reasoning:
`docs/CHAT_SYSTEM_MESSAGES.md`.

| Decision | Effect here |
| --- | --- |
| ✅ **`post_request_opened` retired** | **Shipped.** Submitting writes no system row — the **seed card is the artifact**, carrying the post, the note and View original post. Old threads keep rows written before 2026-09-01; those now arrive as `system.unsupported: true` with a neutral line. Nothing else about submit changed: the seed, `conversation_cleared`, tray placement and the socket events in §3 are all as documented above |
| ❌ ~~status chip on the request card~~ | **Withdrawn 2026-09-01 — don't build it.** Accepted, not-accepted and reopened are each already a system line in the thread; pending is the absence of one. A chip renders the same facts twice. No server field is coming |
| ✅ **"not accepted" replaced "declined"** | **Shipped.** The notification title now reads "Your request to join wasn't accepted" (verb from the post type's catalog, as before), and the thread line reads "Your request wasn't accepted." Event codes and the `post_request_rejected` type are unchanged — don't match on the words |
| ✅ **Post titles in the request lines** | **Shipped 2026-09-01.** "You accepted Arjun's request **for \"Debate Partner\"**." `postTitle` is snapshotted into `system.data` at write time, never resolved from `postId` — a deleted post must not blank an old sentence. `null` on pre-2026-09-01 rows, where the clause is simply dropped (it does **not** mean the post was deleted). Clipped to ~40 chars server-side. Two lines also changed voice: the requester reads "Your request for … was accepted." and, after a reopen, "Your request for … is being looked at again." |

The seed-only decline/withdraw behaviour in §6 (thread clears for both, no
system message, outcome by notification) was **reviewed and kept**. It is
deliberate, not a gap.
