# Chat moderation

The **Chat** screen is a live feed of in-game chat with a report → warn/silence
→ remove ladder. It shares the game's own tables, so actions taken here are the
same records the game and the legacy tooling already understand.

## Data model

### `gcc.chat` — live posts

`id`, `chan`, `name`, `game`, `server`, `post`

- `name` is the **display name**, which may carry a suffix like `Amadea(A)`.
  `nicOf()` strips a trailing `(...)` to get the actual nic.
- `game` is `'1'`/`'gc'` = **GC** (this game) and `'2'`/`'uc'` = **UC** (the
  sister game). `isUC()` is the only place that mapping lives — do not re-derive
  it. Getting this backwards silently mis-routes every action.
- `post` is **`varchar(255)`** (widened by `sql/chat_post_expand.sql`) so a
  scrubbed post can hold a full 150-character replacement plus its wrapper.

### `gcc.chat_abuse` — complaints and outcomes

`id`, `datetime`, `chan`, `name`, `game`, `server`, `post`, `complain`,
`userid`, `usergame`, `readflag`

`readflag` is the status:

| Flag | Meaning |
|---|---|
| 0 | Pending — in the moderation queue |
| 1 | No action |
| 2 | Warned |
| 3 | Silenced |
| 4 | **Removed** (added by this panel) |
| 255 | Duplicate |

There is **no source-id column** — a `chat_abuse` row does not point back at the
`chat.id` it came from. Matching is done on `name` + `server` + `post`. This is
why removal tracking uses the audit log instead (below).

### `gcc.user_pm_abuse` — the player's record

Chat outcomes drop a marker row whose `name2` is one of:

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

These three prefixes are **excluded** from the player's generic *Records* tab
(`Users.cfc`, both the detail and `record` queries) and surface instead on the
dedicated **Chat History** tab, which resolves each `ref:` back to its
`chat_abuse` row for the post and reason. The exclusion matches those exact
prefixes only — never a bare `ref:`, which would hide unrelated records.

## Endpoints (`Chat.cfc`)

| Method | ACL | Does |
|---|---|---|
| `feed` | `chat.view` (0) | Recent posts, newest first, with per-row state flags |
| `report` | `chat.report` (0) — plus `chat.removed.report` (3) if the post is already removed | Files a pending `chat_abuse` complaint |
| `act` | `chat.action` (3) | Warn or silence directly |
| `remove` | `chat.remove` (5) | Scrubs the post text |
| `history` | `players.chathistory.view` (3) | One player's chat outcomes |

### `feed`

Orders by `id DESC` (that is how "newest" is defined — there is no timestamp on
`chat`), pages with `before`, and filters by game. Each row carries:

| Field | Meaning |
|---|---|
| `actioned` | A resolved complaint (readflag 2 or 3) exists for this post |
| `reported` | A **pending** complaint (readflag 0) exists |
| `removed` | An admin has scrubbed this post |
| `original` | The pre-scrub text — **only** sent when the viewer has `chat.removed.view` |

`actioned`/`reported` are matched on `name|server|post`. For a removed post the
match uses the **original** text, because that is what the warn/silence record
stored — otherwise a warned-then-removed post would look un-actioned.

The response also carries `canAct`, `canReport`, `canReportRemoved`,
`canRemove`, `canViewRemoved` for UI gating.

### `act` — warn / silence

On **GC**: creates a `chat_abuse` row already resolved (readflag 2 or 3), PMs the
offender with a `Ref YYYYMMDD-<id>` code, drops the `ref:` record on their
account, marks other pending complaints for the same post as duplicates (255),
and — for a silence — sets the game's mute flag `server["gc_mute<uid>"] = 1`,
the same flag `i_chatb.cfm` reads.

On **UC**: this panel cannot sanction a UC player. The action is **converted**
into a pending report (readflag 0) and the response sets `converted: true` with
a message telling the admin to finish it in the UC panel.

If the player no longer exists on that server the complaint is still recorded
and the response says so.

### `remove` — scrub a post

The extreme action. Deliberately gated at 5 and deliberately separate:

- Requires a **reason** (mandatory) and takes an optional **replacement**
  (blank → `[Message removed by an admin]`, max 150 chars).
- Updates `chat.post` to the replacement **wrapped for display**:
  `<span class="removed">…</span>`.
- Writes a `chat_abuse` row at **readflag 4** preserving the **original** text,
  with `complain` = `"Removed: <reason>"`.
- Drops a `Chat removed ref:<id>` record on the player.
- Audit-logs the original, the replacement, and the reason.

It works on **any** post, including one already warned or silenced — a post bad
enough to warrant deletion must still be removable after a lighter action.
Conversely a removed post can still be warned/silenced afterwards. The two
ladders are independent, in both orders.

## Staff posts

The **Post to game chat** box on the Chat screen (`chat.post`, default 5) says
something in the game's public chat: always GC (`game` `'1'`), channel 1,
server 4.

- **The name is stored styled:** `<span class="gc-chat-staff" data-admin="<id>">Name</span>`,
  where `<id>` is the `admin_account` that posted.
  `chat.name` and `chat_abuse.name` were widened to `varchar(80)` for it
  (`sql/chat_name_expand.sql`); `chat_abuse` too because removing a post
  copies its name, and with strict mode an over-long name is an error.
- **One shape, one owner.** `gcChatStaffName()` / `gcChatStaffAdmin()` in
  `Modules/Functions/Functions.cfm` recognise it — the exact span, optional
  `data-admin`, name of 1-20 `A-Z a-z 0-9 space . _ -` — on the **raw** value. Player names are stored
  entity-encoded, so no player can produce it; matching after entity decoding
  would let one spell it. The player API (`api/components/Chat.cfc`,
  `splitBadge`) repeats the pattern because it does not load that file.
- **Text** is escaped (`& < > "`) because the game prints `chat.post` raw, and
  capped at the game's own 150. The admin feed decodes entities for display
  (text nodes only), for player posts too — the game escapes `< > "` itself.
- **Complaints tie to the admin.** A staff post can be reported, in game or
  from the feed. `chat_abuse` has no column pointing at the post, but the
  complaint copies the name, and the `data-admin` id inside it says who wrote
  it. `Base.staffPostAuthor()` turns that into `{ id, username, displayName }`:
  the feed shows *by \<admin\>*, the queue, Dismissed and the complaint page show
  *Posted by: Staff post by \<admin\>* instead of *not found / renamed*. The
  admin's name links to `#/audit?username=<admin>` (the Audit Log reads that
  parameter and pre-fills its Admin filter) for those with the `audit`
  section; below it, plain text. A post
  made before the id was stored falls back to its `chat.post` audit entry — by
  chat id in the feed, by name + text for a complaint.
- **No sanctions.** There is no player account behind a staff post, so `act`
  refuses it, the feed hides Warn / Silence, and `chatResolve` accepts only
  **No action**: the complainer gets their reply (with the reason if ticked),
  and the audit entry targets the admin account (`target_type` `admin`,
  `staffPost` / `staffAdminId` / `staffAdmin` in the detail). **Remove** works.
- `server.OnlineChat_1c` is bumped like `i_chatb.cfm` does, so the game's chat
  frames notice the post. Every post is audit-logged as `chat.post`.

Where the name renders, and how:

| Surface | How |
|---|---|
| Side chat on every page (`i_f_800.cfm`) | Prints `chat.name` raw — styled by `theme.css` |
| `f_com_msgsector.cfm` | Raw too. Its player link is only drawn when `game` equals `application.server_type` (`gc`), which a `game` `1` post never does, so the page is untouched |
| `f_com_msgsector2.cfm` | `gcChatName()` / `gcChatNameAttr()` — rebuilds the span from the inner name (without `data-admin`), never echoes the stored value |
| Player API | Plain name + `badge: "admin"` |
| Admin feed and moderation screens | `staff: true` + plain name + `postedBy`; `.gc-chat-staff` in `admin.css` |

`.gc-chat-staff` — sapphire, with a sapphire STAFF tag — is defined in
`app/theme.css` (all three themes; the tag is CSS-generated, never stored; the
side chat drops its name underline for staff names via `:has()`) and
`assets/admin.css` — like `.removed`, a class
for one stored value that renders on more than one surface.

## Preserving the original

Because `chat_abuse` cannot point at a `chat.id`, the **audit log is the
authoritative link**. `removalOf(chatId)` reads the oldest `chat.remove` entry
for that target id and returns `{ removed, original }`.

Every path that records what a player said runs through it:

- `act()` and `report()` store the **original** in the new `chat_abuse` row and
  the player PM, so the record shows what was actually said — not `[removed]`.
- The Report/Warn/Silence modals show the original.
- The feed's `actioned`/`reported` matching keys on the original.

The result: **scrubbing changes what players see, never what the record says.**

## Display of a removed post

The stored value is markup on purpose — only an admin can produce it, never a
player — and each surface styles `.removed` itself:

| Surface | Where the CSS lives |
|---|---|
| Modern side chat (main UI) | `app/theme.css` |
| Classic chat frame | `app/i_chatt.cfm` (inline `<style>`; every chat surface renders into this frame) |
| Admin panel feed | `app/Admin/assets/admin.css` |

The wrapper carries **no leading `<br />`** — layout belongs to the view. The
main-UI side chat (`app/i_f_800.cfm`) puts the author above the post itself with
a thin `<hr class="gc-sidechat__rule">` between them (tight 2 px margins: a
visible divider, not a gap). `f_com_msgsector.cfm` is intentionally left inline
and unmodified.

The admin feed strips the wrapper and re-renders the text as a **text node**
inside its own `.removed` span, so player-authored content is never passed
through `innerHTML`.

## UI rules

- **Report** disappears once a post has a pending complaint (replaced by
  "✓ Reported") — it has already been escalated.
- **Warn/Silence** collapse to "✓ Already actioned" once resolved.
- **Remove** shows only while the post is *not* yet removed, then simply
  disappears — the scrubbed text and the expander make the state obvious, so no
  marker is used.
- A removed post offers a click-to-expand "show original post" reveal, the same
  interaction as battle reports in the event feed, gated on `chat.removed.view`.
- Posts are always rendered as text nodes, never `innerHTML`.

## Moderation queue

`Moderation.cfc` owns the queue side (`chatQueue`, `chatDetail`, `chatResolve`).
`chatDetail` fetches **any** complaint by id regardless of `readflag`, which is
what makes the deep link work:

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

The Chat History tab's "Reference" column links there. When `cid` is present the
Chat tab opens that complaint directly instead of the pending queue — necessary
because a resolved complaint (readflag ≥ 1) is not in the queue at all. A
resolved complaint renders read-only with its status badge instead of the
resolve form.

### Recent actions — the global log

`moderation.chatActions` (Moderation tab **Recent actions**) is the global
counterpart to the per-player **Chat History** tab: every warning, silence and
removal, newest first, presented with the same columns and badge colours plus
**Player** and **Admin** — the two things a single-player view has no need to
say. Each chat row links back to its complaint via `?cid=`.

It also carries **PM complaint outcomes** (warning, 3-day freeze, suspend,
blacklist) — see *PM complaints in the log* below. Dismissals are not actions;
they have their own tab.

It reads the **same source** as `Chat.history()` — the `ref:` marker rows on
`user_pm_abuse`, matched on the same three exact prefixes — for two reasons:

- `chat_abuse` has **no admin column**, so the marker row is the only record of
  *who* took the action.
- Sharing the source means the global log and a player's own history can never
  disagree about that player. The prefix → label mapping lives once, in
  `Base.chatActionType()`; do not re-derive it in a third place.

Paging is on `user_pm_abuse.rowid` (`before` cursor, `limit` 1–500 default 100),
for the same reason the feed pages on `chat.id`: it is the primary key, and the
ordering and the cursor should be the same column.

A record **outlives the account** it was written against, so the join to `user`
is a `LEFT JOIN` and a missing player yields an empty `nic`; the UI shows the id
rather than dropping the row. Deleted players are exactly the ones a moderation
history is most often consulted about.

It carries its own section, `moderation.chatlog` (**default 3**), rather than
riding on `moderation.chat` (2): this is the same data as the per-player Chat
History tab, which sits at 3, and reaching it for every player at once should
not require less clearance than reaching it for one.

#### PM complaints in the log

A PM complaint is resolved **in place**: the complaint row itself becomes the
record (`aflag` 1, `name2` = the outcome, `admin` = who). It lives in the same
table as the chat markers, so one `rowid` cursor pages both. The outcomes
matched are the panel's `PM warning ref:` / `PM 3d freeze ref:` /
`PM suspend ref:` / `PM blacklist ref:` and the legacy panel's bare
`Send Warning` / `3 Days Freeze` / `Blacklist`, all constants at the top of
`Moderation.cfc` (`PM_ACTION_SQL`, mapped to labels by `pmActionType()`).

- **Gated.** PM rows are included only for an admin with `moderation.pm` (4),
  so the log (3) cannot become a way round the PM queue's level. The response
  says `withPm`.
- **Links** go to `#/moderation/pm?pid=<id>`, which opens the complaint
  read-only once resolved. Chat and PM ids overlap, so the link must name the
  queue — a PM id sent to `?cid=` reads *Complaint not found*.
- **When.** A complaint row never recorded when it was resolved. Anything
  resolved in this panel takes the time from its `moderation.pmResolve` audit
  entry (the action date). Older ones fall back to when the reported message
  was sent, then to when the complaint was filed; if the message is gone and
  the row has no time, the cell shows a dash.
- **Deleted messages.** Reported messages are purged over time, so most resolved
  complaints point at a message that is gone. `pmDetail` starts
  from `user_pm_abuse` and `LEFT JOIN`s the message, so the complaint still
  opens and says the message was deleted.

### Dismissed complaints

`moderation.dismissed` (tab **Dismissed**) lists complaints closed with **no
action**, newest first, with a Chat / PM switch. The two live in different
tables with their own ids, so each pages on its own key.

| Kind | Source | Pages on |
|---|---|---|
| `chat` | `chat_abuse` readflag **1** — "No action", and complaints closed because the player no longer existed | `chat_abuse.id` |
| `pm` | resolved complaint rows whose `name2` is `PM complain: no action` or the legacy `PM not offensive` (`PM_DISMISS_SQL`) | `user_pm_abuse.rowid` |

Duplicates (readflag 255) are not listed: they were closed because another
report of the same post was acted on, not dismissed.

**Dismissed by / when / reason** come from the resolve's audit entry
(`resolveAudit()` matches `detail.complaintId`, or `target_id` for a chat
complaint closed as player-not-found), so they are known for anything
dismissed in this panel and blank before it. A PM row does carry `admin`
itself, so its "By" is always filled. A PM complaint with no filed time falls
back to the reported message's sent time.

Section `moderation.dismissed` (**default 3**), same reasoning as the log:
every player's history at once. `kind=pm` also requires `moderation.pm`, and
the PM chip is hidden below it.

### The Resolution card

A resolved complaint (chat `?cid=`, PM `?pid=`) opens read-only with a
**Resolution** card: outcome, who, when, and the reason the admin gave — the
same reason the Recent actions and Dismissed lists show — plus who it was
shared with ("Shown to the offender / complainer"). `chatDetail` and `pmDetail`
return it as `resolution`.

| Source | Gives |
|---|---|
| Audit entry — `moderation.chatResolve` / `.pmResolve` (`complaintId`), `chat.act` / `chat.remove` from the live feed (`abuseId`) | who, when, reason, shared-with (`audited: true`) |
| No audit entry: a chat warning / silence / removal `ref:` marker | who, when |
| No audit entry: a PM complaint row | who (`admin`) |

Anything missing reads **not recorded**; a reason that was audited but left
blank reads **none given**. A duplicate (255) shows only its outcome and that it
closed with another report.

### Audit times on these screens

`admin_audit_log.created_at` is stamped by the **database** clock; the game rows
these times sit beside were stamped by CF `now()`, and locally the two differ
(UTC vs the game's zone). Shown raw, a complaint read as dismissed four hours
after it was filed. `resolveAudit()` shifts audit times onto the game clock by
the measured difference, rounded to a quarter hour. The Audit screen itself
still shows raw times.
