---
name: admin-chat-moderation
description: Work on the Admin chat moderation system — the live feed, report/warn/silence/remove ladder, chat_abuse readflags, the ref: records on a player, GC vs UC routing, and preserving the original text of scrubbed posts. Use when touching Chat.cfc, views/chat.js, the moderation chat queue, or how removed posts render in game.
---

# Chat moderation

Full model: `docs/CHAT-MODERATION.md`. This is the operating guide for changing
it safely.

## Facts you must not re-derive

- **`game` mapping is `'1'`/`'gc'` = GC, `'2'`/`'uc'` = UC.** `isUC()` in
  `Chat.cfc` is the only place it lives. Getting it backwards silently
  mis-routes every action, and it looks like it works.
- **`chat_abuse` has no source-id column.** It does not point back at a
  `chat.id`. Matching is `name` + `server` + `post`.
- **The audit log is the authoritative `chat.id → original text` link.**
  `removalOf(chatId)` reads the oldest `chat.remove` entry and returns
  `{ removed, original }`.
- **`name` is a display name** and may carry a suffix (`Amadea(A)`). Use
  `nicOf()` to get the nic before looking up a `user` row.
- **`chat.post` is `varchar(255)`** (widened by `sql/chat_post_expand.sql`).

## readflag

| Flag | Meaning |
|---|---|
| 0 | Pending — in the moderation queue |
| 1 | No action |
| 2 | Warned |
| 3 | Silenced |
| 4 | **Removed** |
| 255 | Duplicate |

Keep the CFML side and `READFLAG` in `assets/views/moderation.js` in step; the
map drives the status badge everywhere a complaint is shown.

## The records trail

Outcomes drop a `user_pm_abuse` row whose `name2` is one of:

```
Chat warning ref:<abuseId>
Silenced ref:<abuseId>
Chat removed ref:<abuseId>
```

If you add a fourth outcome you must touch **three** places or it will leak:

1. Write the new prefix in `Chat.cfc`.
2. Add it to the `history()` query and its type parsing.
3. Add it to **both** `NOT LIKE` strips in `Users.cfc` (the detail query and
   `record`), so it stays off the generic Records tab.

Match the **exact prefix**, never a bare `ref:` — that would hide unrelated
records.

## Independence of the ladders

Removal and warn/silence are **independent, in both orders**:

- A removed post can still be warned or silenced (it links to the original).
- An actioned post can still be removed — sometimes the public-facing removal is
  the urgent part and the sanction follows.

Never gate one on the other. The only thing that locks is a *repeat of the same
action*: Report hides once a complaint is pending, Warn/Silence collapse once
resolved, Remove disappears once the post is scrubbed.

## Preserving what was said

Every path that records a player's words must use the **original**, not the
scrubbed replacement:

```cfc
var recordedPost = originalIfRemoved(chatId, toString(c.post));
```

That covers `act()` and `report()` — the `chat_abuse` row, the player PM, the
audit detail, and the duplicate-marking `UPDATE`. The feed's `actioned` /
`reported` matching also keys on the original, or a warned-then-removed post
reads as un-actioned.

**Scrubbing changes what players see, never what the record says.**

## GC vs UC

This panel cannot sanction a UC player. `act()` on a UC post converts the action
into a **pending report** (readflag 0) and returns `converted: true` with a
message pointing at the UC panel. Removal is *not* UC-gated — scrubbing a message
is cleanup, not a player sanction.

## How a removed post renders

Stored value is markup on purpose (only an admin can produce it):

```html
<span class="removed">[Message removed by an admin]</span>
```

No leading `<br />` — layout belongs to the view. `.removed` must exist in **all
three** surfaces or it silently looks fine on the one you tested:

| Surface | File |
|---|---|
| Modern side chat | `app/theme.css` |
| Classic chat frame | `app/i_chatt.cfm` (inline `<style>`; all chat surfaces render into this frame) |
| Admin feed | `app/Admin/assets/admin.css` |

The main-UI side chat (`app/i_f_800.cfm`) puts the author above the post with
`<hr class="gc-sidechat__rule">` between them. `f_com_msgsector.cfm` is
deliberately left inline — **do not modify it**.

In the admin feed, strip the wrapper and re-render the text as a **text node**
inside its own `.removed` span (`cleanRemoved()` handles wrapped and legacy plain
values). Player content never goes through `innerHTML`.

## Deep links

```
#/moderation/chat?cid=<chat_abuse id>
```

`chatDetail` fetches any complaint regardless of `readflag`, which is what makes
this work — a resolved complaint is **not** in the pending queue, so without the
`cid` branch the page would just say "Queue is clear." Resolved complaints render
read-only with a status badge instead of the resolve form.

## ACLs

| Section | Def | Gates |
|---|---|---|
| `chat.view` | 0 | The feed |
| `chat.report` | 0 | Reporting a live post |
| `chat.removed.report` | 3 | Reporting an **already-removed** post (escalating an admin's decision) |
| `chat.action` | 3 | Warn / silence |
| `chat.remove` | 5 | Scrubbing a post |
| `chat.removed.view` | 3 | Seeing the original text of a removed post |

`chat.removed.view` controls the `original` field in the feed payload — below it
the server sends `""`, it isn't merely hidden client-side.

## Verifying a change here

Chat actions fan out into **four** game tables plus the audit log:
`chat`, `chat_abuse`, `user_pm_abuse`, `user_pm`, and `admin_audit_log`.

Test at minimum:

1. Action a normal post → correct readflag, correct `ref:` record.
2. Remove, then warn → the warn record holds the **original** text.
3. Warn, then remove → both recorded, both markers correct.
4. A UC post → converts, does not sanction.
5. The ACL gate from both sides.
6. Records tab does **not** show the chat `ref:` rows; Chat History does.

Then delete every row you created and restore any post you scrubbed — capture
the original **before** you mutate it. Follow `admin-verify` for the cleanup
discipline; a careless `DELETE … OR …` here can destroy the record you were
about to restore from.
