# Audit logging

Everything an admin does — and, deliberately, most of what they *look at* —
lands in `gcc_admin.admin_audit_log`. The **Audit Log** screen reads it, and the
Overview's "Recent Admin Activity" card shows the tail.

## The three writers

| Helper | Use for | Detail column |
|---|---|---|
| `auditLog(action, targetType, targetId, targetLabel, detail)` | A mutation | Free-form; struct is serialised to JSON |
| `auditView(action, targetType, targetId, targetLabel)` | Opening/reading a screen | `{"view": true}` |
| `logChange(action, uid, nic, entity, entityId, fields, reason, notifyPlayer)` | A field-level edit with before → after | `{entity, fields:[{f,from,to}], reason}`, plus `silent: true` when the player was not notified and `reasonShown: true` when the reason was included in their PM |

All three ultimately write one `admin_audit_log` row:
`account_id`, `username`, `action`, `target_type`, `target_id`, `target_label`,
`detail`, `ip`, `created_at`.

### `auditLog` — mutations

Call it **after** the write succeeds, so a failed action doesn't leave a
misleading entry.

```cfc
auditLog("fed.update", "fed", fid, toString(f.name), { "changes": changes, "reason": reason });
```

When `targetLabel` is empty and `targetType` is `player`, the helper looks up the
nic itself, so a row reads `player.note  cagito` rather than a bare user id.

### `auditView` — reads

```cfc
auditView("economy.topView", "system", res, "Richest by #res#");
```

Two behaviours that matter:

- **15-second dedupe.** A repeat of the same `account_id` + `action` +
  `target_id` inside 15 seconds is dropped, so refreshes and re-renders don't
  spam the log.
- **Fail-soft.** It never throws; a logging problem must not break a read.

Call it **after** the data is fetched but before `apiOut`, so a `404`/`403` path
doesn't record a view that never happened. (`Audit.search` logs after its own
fetch specifically so the entry can't appear in the page it just returned.)

### `logChange` — verbose edits

Produces the before → after rows the Audit screen and the per-player change logs
render. A blank reason is stored as **"Quick Edit"**, so every entry reads
*"\<scope\> edited: \<reason\>"*.

```cfc
logChange("player.fleetEdit", uid, nic, "Fleet", fleetId,
          [ { "f": "Land", "from": "2,000", "to": "2,001" } ], reason);
```

### `logChange` also PMs the player — and the one way to stop it

`logChange` calls `notifyPlayerOfChange()`, which PMs the player a plain-language
summary ("500,000,000 Credits removed (…)"). It is hooked there rather than in
each save endpoint precisely so a scope added later cannot forget it.

The trailing `notifyPlayer` argument is the single deliberate exception, and it
**defaults to `true`** — a caller has to ask for silence, so that guarantee still
holds everywhere that does not opt out. Today only `users.resourcesSave` passes
`false`, driven by the "Do not notify the player" box in the resources editor.

**Silence never reaches the audit log.** When `notifyPlayer` is false the detail
gains `silent: true`, so a quiet edit is *more* identifiable in the trail, not
less. What the option hides is the notification to the **player** — never the
record of what an admin did:

| | Notified (default) | Silent |
|---|---|---|
| `admin_audit_log` entry | yes | yes, marked `silent` |
| `user_pm_abuse` record | yes | yes |
| PM to the player | yes | **no** |
| Forced re-log (`admin_reset`) | yes | **no** |

If you add another silent-capable editor, suppress the PM and the re-log
together — a forced logout is itself a notification (see
`Skills/admin-game-integration`).

### Sharing the reason with the player

By default the admin's reason is an **internal note**: it goes in the audit
entry and nowhere else, because reasons routinely say things a player should not
read ("suspected of X, watching them").

Every player-editing dialog carries a **"Show this reason to the player"**
tickbox, unticked. When it is ticked the request body gains `showreason=1`, and
`logChange()` appends the reason to the PM under a `Reason given:` heading,
capped at the same 200 characters the audit stores so the two can never show
different halves of one sentence.

The flag is read inside `logChange()` — via `reasonShownToPlayer()`, straight off
`request.apiBody` — rather than threaded through each call. Same reasoning as
the PM hook itself: there are a dozen player-editing endpoints, and one that
forgot to pass it would silently drop the reason with nothing to show it had.
**A new editing section gets the option as soon as its form sends the field; no
endpoint change is needed.**

`reasonShown: true` is added to the audit detail **only when the player was
really told** — it means "we shared this", not "someone asked to". So:

| Ticked | notifyPlayer | Player sees the reason | Audit detail |
|---|---|---|---|
| no | true | no | `reason` only |
| yes | true | **yes**, in the PM | `reason` + `reasonShown` |
| yes | false (silent) | no — no PM is sent at all | `reason` + `silent` |

That last row is deliberate: **"do not notify" outranks "show the reason"**,
because there is no message to put it in. Logging `reasonShown` there would
claim the player was told something they never received.

A blank reason is never shared, whatever the box says — there is nothing to
show, and the audit would otherwise read as though "Quick Edit" had been sent to
the player.

### The same box on complaint verdicts

The chat and PM complaint resolve forms (Moderation) carry the same tickbox,
with the same rules. `moderation.chatResolve` / `.pmResolve` do not go through
`logChange()`, so each applies the rule itself — ticked **and** a non-blank
reason **and** a message that actually goes out — and adds the reason with the
shared `reasonForPlayer()` block, so the wording cannot drift from the player
editors'. It goes to whoever the verdict's message is for: the offender on a
sanction, the complainer's reply on No action (explaining why nothing was
done). Suspend and Blacklist send the offender nothing and play the part of "do
not notify": nothing is shared. The audit detail records `reasonShownTo`
(`offender` / `complainer`) alongside `reasonShown`.

## Choosing `targetType`

The Audit screen turns some target types into links, so pick deliberately:

| `targetType` | `targetId` | Rendered as |
|---|---|---|
| `player` | numeric uid | link to `#/players/<id>` |
| `fed` | numeric fed id | link to `#/feds/<id>` |
| `chat` | chat post id | plain |
| `ip` / `email` | the value | plain |
| `admin` | admin account id | plain |
| `colony` | colony id | plain |
| `system` | a stable slug | plain |

For `system`, choose a `targetId` that makes repeat views distinguishable where
that's useful — and *identical* where it isn't. Examples in the codebase:

- `economy.topView` uses the resource (`credit`, `ore`, …) so each ranking logs
  separately.
- `anticheat.turnTop` encodes the day window (`7d`, `5d`) so changing the filter
  is recorded as a distinct look, not deduped away.
- `dashboard.view` uses the constant `overview` so refreshes collapse.

## What gets logged

### View actions

| Action | Where |
|---|---|
| `dashboard.view` | Overview load |
| `moderation.chatActions` | Moderation → Recent actions. `target_id` `1` when PM rows were included, `0` when not |
| `moderation.dismissedView` | Moderation → Dismissed; `target_id` is `chat` or `pm` |
| `server.announcementView` | Server Settings — announcement banner |
| `audit.view`, `audit.gameHistoryView` | Audit screen, both tabs |
| `admins.listView` | Admin accounts list |
| `admins.guideListView` | In-game staff list (Admins -> In-game staff list) |
| `economy.view`, `economy.topView` | Economy totals; each richest-by filter |
| `fed.listView`, `fed.view` | Federation list; a specific federation |
| `anticheat.sharedIp`, `anticheat.turnTop`, `anticheat.interAttacks` | Every anti-cheat tool, incl. filter changes and player lookups |
| `events.watch` / `events.basicWatch` | Global event watch, by tier |
| `gamedata.race`, `.ship_type`, `.planet_type`, `.restree`, `.project`, `.good` | Game Data tabs |
| `player.view` | Opening a player |
| `player.fleetView`, `.colonyView`, `.coloniesView`, `.artifactsView`, `.projectsView`, `.resourcesView`, `.marketView`, `.recordView`, `.notesView`, `.loginsView`, `.chatHistoryView` | Player tabs |
| `player.eventsView` / `player.eventsBasicView` | Player events, by tier |

### Mutations

| Action | Where |
|---|---|
| `player.<action>` | Every `UserActions` action — `comment`, `suspend`, `unsuspend`, `blacklist`, `freeze`, `silence`, `restart`, `disableTurns`, `refreshTech` (named dynamically as `"player." & actionName`) |
| `player.note`, `player.noteDelete` | Case notes |
| `player.colonyEdit`, `.fleetEdit`, `.artifactEdit`, `.projectEdit`, `.projectAdd`, `.projectRemove`, `.resourceEdit`, `.infraEdit`, `.marketEdit`, `.profileEdit`, `.researchEdit` | Player editors (via `logChange`) |
| `chat.report`, `chat.act`, `chat.remove` | Chat moderation |
| `chat.post` | A staff post to game chat; detail carries `name` and `text`. `target_id` is the chat id — the fallback link from an older post (no `data-admin`) to its admin |
| `moderation.chatResolve`, `.pmResolve`, `.ipBan`, `.ipBanRemove`, `.emailBan`, `.emailBanRemove` | Moderation queues and blacklists |
| `fed.update`, `fed.resetNotices` | Federation management |
| `gamedata.edit` | Game data definitions |
| `admin.create`, `.update`, `.setActive`, `.resetPassword`, `.bootstrap` | Admin accounts |
| `admins.guideSave`, `admins.guideRemove` | In-game staff list. Detail carries the G/M/A flags — `guideRemove` records what the row held on the way out, since the row itself is gone |
| `acl.set` | Access control change |
| `server.runtimeRefresh`, `server.setSetting` | Server ops |
| `server.announcement` | Announcement banner posted or taken down. Detail carries `live` and `cacheRefresh` — the status of the `i.cfm?yellowmsg=1` call that busts the game's 5-minute banner cache, so a banner that was slow to move can be told apart from one that was slow to be posted |
| `player.relogHookFailed` | Diagnostic: the force-relog hook didn't fire |

## The audit log as a data source

`chat.remove` stores the **original** post text in its detail JSON, and that
entry is the authoritative `chat.id → original text` link (the `chat_abuse`
table has no source-id column). `Chat.cfc`'s `removalOf()` reads it back to
decide whether a post was scrubbed and to recover what was actually said.

The lesson generalises: audit detail is queryable, structured, and permanent.
When a feature needs to remember *what something used to be*, the audit entry is
often the right home — but **do not** delete or rewrite audit rows to make a
feature work. Nothing in the panel updates or deletes from `admin_audit_log`.

## Conventions

- Action names are `module.thing` or `module.thingView`, lowerCamel after the
  dot. Views end in `View` or start a `*.watch`-style pair when tiered.
- Log the **effective** thing that happened, not the request: a UC chat action
  that converts to a pending report logs `"converted": "UC report"`.
- Include the `reason` in detail whenever the UI collected one.
- Truncate long free text before storing (`left(text, 200)`), so one entry can't
  bloat the table.
