# Mobile Content Moderation — Integration Guide

> Blocked-word filtering is no longer chat-only. **Every** request body and
> query string the app sends is now checked server-side, so any screen with a
> text input can come back with a 422. One rejection shape, one handler.
> Chat's existing behaviour is unchanged — see
> [`mobile-chat-moderation-guide.md`](../chat/mobile-chat-moderation-guide.md)
> for the composer-specific detail.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <accessToken>` on every endpoint that had it before

## 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-07** — Blocked-word checking now covers every text field in the app, not just chat. Posts, comments, events, your profile, activities, achievements, school submissions, onboarding and the search box can all return **422 `CONTENT_BLOCKED`** with a `field` telling you which input to highlight. Chat keeps `MESSAGE_BLOCKED` exactly as before. Report descriptions are deliberately never filtered.

---

## How it works in one paragraph

The server runs one gate in front of every route. It walks the whole request
body (including nested objects and arrays) and the query string, and rejects the
request if any user-typed value trips the blocklist. There is nothing to opt into
and no new endpoint — existing endpoints simply gained a rejection case. The
client-side list you already ship for the chat composer is still UX only; the
server is the boundary and always re-checks.

---

## The rejection shape

**HTTP 422 Unprocessable Content** — the request is well-formed, its content
violates policy (not a 400 malformed request).

```jsonc
{
  "success": false,
  "error": "Please use kinder words.", // display-ready, kid-appropriate
  "code": "CONTENT_BLOCKED",           // switch on this, not the status code
  "reason": "PROFANITY",               // may gain values later
  "field": "data.profile.bio",         // dotted path into YOUR request body
}
```

`field` is the path of the offending value inside the request you sent —
`"content"`, `"title"`, `"media[1].altText"`, `"data.profile.bio"`, or `"q"` for
a query string. Use it to focus and outline the right input. It is the only new
property; everything else matches the chat contract you already handle.

Three codes exist. They differ only in copy — treat all three the same way:

| Code                  | Where                                                                                                            |
| --------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `MESSAGE_BLOCKED`     | `POST /api/chat/conversations/:id/messages`, `PATCH /api/chat/messages/:messageId`, `POST /api/posts/:id/requests` |
| `SCHOOL_NAME_BLOCKED` | `POST /api/schools`                                                                                                |
| `CONTENT_BLOCKED`     | everywhere else                                                                                                    |

> **If you only handle 422 generically, you are already correct.** The two older
> codes are byte-identical to what shipped before (same status, code, `reason`
> and copy — `field` is purely additive), and `CONTENT_BLOCKED` only appears on
> endpoints that never returned a 422 at all. A screen with no special case
> falls through to its normal error path and shows `error`, which is written to
> be displayed as-is.

There is one more `reason` you can receive: **`UNSCANNABLE`** (no `field`). It
means the payload was too deep or too large for the server to inspect and was
rejected rather than waved through. A normal screen cannot produce it — if you
see it, you are sending something pathological. Treat it as a bug in the request.

---

## Where it applies

Everything a user types, in the body or the query string:

| Surface                                                                | Fields                                                             |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `POST /api/posts`, `PATCH /api/posts/:id`                              | `title`, `content`, `location`, `details`                          |
| `POST /api/posts/:id/comments`                                         | `content`                                                          |
| `POST /api/events`, `PATCH /api/events/:id`                            | `title`, `description`, `location`                                 |
| `PATCH /api/users/me/profile`                                          | `displayName`, `bio`, `location`                                   |
| `POST /api/auth/register`                                              | `username`, `displayName`                                          |
| `POST/PATCH /api/users/me/activities`, `…/achievements`, `…/education` | `title`, `role`, `description`, `typeOther`, `shareCaption`, `schoolName`, `grade`, `city`… |
| `POST /api/onboarding/session`, `PATCH …`, `POST …/complete`           | the whole nested `data` blob                                       |
| `POST /api/chat/conversations`, `PATCH /api/chat/conversations/:id`    | group `name`                                                       |
| `POST /api/schools`                                                    | `name`, `state`, `district`                                        |
| `GET /api/search`, mention search, school search, GIF search           | `q`                                                                |
| any media payload                                                      | `altText`                                                          |

**Search is gated too.** A blocked query returns 422 instead of an empty result
set — show the message rather than "no results".

### Never moderated

- **Passwords** (`password`, `newPassword`) and the login `identifier`. A
  password containing an awkward substring still works, and an existing account
  whose username predates this gate can still sign in.
- **Emails, OTP codes, tokens, cursors, storage keys, file names, ids, slugs,
  URLs and timestamps.** Machine strings, not prose.
- **Path parameters.** `GET /api/users/:username` is a read and is never gated.
- **`POST /api/reports` → `details`.** Deliberate: reporting bullying means
  quoting it. Never filter the report box in your UI either — let the child type
  what happened. Everything else about the endpoint is unchanged (auth, rate
  limit, 500-char cap).

---

## What to build

1. **One shared 422 handler.** On `status === 422` with a `code` ending in
   `_BLOCKED`, show `error` and, if `field` is present, focus that input.
2. **Keep the composer pre-check you already have** for chat, and reuse the same
   helper on the other big text inputs (post body, comment, bio, event
   description) so the user gets instant feedback before the round trip. Copy
   the `CLIENT_BLOCKED_TERMS` block and matcher from
   [`mobile-chat-moderation-guide.md`](../chat/mobile-chat-moderation-guide.md) —
   it is the same list, it is not chat-specific.
3. **Roll back optimistic UI.** A blocked request never reaches the handler:
   nothing is written, no socket event fires, no rate-limit budget is spent.

### Don't

- **Don't rely on the client check.** It is a courtesy; the server 422 is the
  boundary.
- **Don't echo the blocked word** back to the user or into analytics. The
  response deliberately never tells you which word tripped.
- **Don't reconstruct the server list.** You only ever hold the small
  superficial one.
- **Don't pre-filter the report box.**

---

## One ordering detail

**401 wins.** On any authenticated endpoint, a request with no `Authorization`
header gets the normal 401 — never a 422 — so a signed-out user is told to sign
in, not told to watch their language. The gate only ever fires for a caller who
presented a token.

The exception is the endpoints that are public by design — `POST /api/auth/register`
and the onboarding routes. Those *are* how an unauthenticated user submits text
(`username`, `displayName`, the onboarding `data` blob), so they stay gated with
no token and can return 422.

---

## Reference

- Chat-specific behaviour and the composer list:
  [`mobile-chat-moderation-guide.md`](../chat/mobile-chat-moderation-guide.md)
- Reporting and blocking:
  [`mobile-report-block-guide.md`](../user-actions/mobile-report-block-guide.md)
