Link prior moderation actions to case decisions

This commit is contained in:
2026-10-17 19:42:05 -04:00
parent ef96a2c308
commit aca728b557
13 changed files with 948 additions and 17 deletions
+9
View File
@@ -149,6 +149,15 @@ the required permission and reason, including after a case closes. Reopening a
case also leaves its restrictions in place. Check enforcement coverage after case also leaves its restrictions in place. Check enforcement coverage after
applying or revoking a restriction. applying or revoking a restriction.
Use **Link existing action** to associate a warning, kick, private message, ban, or
mute already recorded for this player with the case. This preserves the original
action, author, time, delivery state, and expiry; it does not issue another action
or extend a restriction. The same action can support more than one related case.
Linking requires the current case revision and a reason, and appears in its timeline.
When closing with **Warning issued** or **Restriction applied**, optionally reference
one of the linked records. Warning references require a confirmed warning; failed
or uncertain delivery remains visible without being presented as a confirmed warning.
Staff notes are explicitly labeled as notes. In match chat, select up to 100 Staff notes are explicitly labeled as notes. In match chat, select up to 100
messages and choose **Attach to case** to open a case or add them to an existing messages and choose **Attach to case** to open a case or add them to an existing
case for the selected subject. Select at least one message from that subject; case for the selected subject. Select at least one message from that subject;
+12
View File
@@ -125,11 +125,23 @@ export interface ModerationCaseDetail {
case: ModerationCase; case: ModerationCase;
player: ModerationPlayer; player: ModerationPlayer;
sanctions: ModerationSanction[]; sanctions: ModerationSanction[];
linkedActions?: ModerationCaseAction[];
availableActions?: ModerationCaseAction[];
events: ModerationEvent[]; events: ModerationEvent[];
coverage: ModerationCoverage[]; coverage: ModerationCoverage[];
assignees: { id: string; username: string }[]; assignees: { id: string; username: string }[];
checkedAt: string; checkedAt: string;
} }
export interface ModerationCaseAction {
id: string;
type: 'restriction' | 'action';
kind: string;
reason: string;
actor: string;
createdAt: string;
state: string;
expiresAt: string | null;
}
export interface ModerationEvent { export interface ModerationEvent {
id: number; id: number;
game: ModerationGame | null; game: ModerationGame | null;
+11 -2
View File
@@ -28,7 +28,11 @@ import { migrateBank } from './bank.js';
import { migrateAfk } from './afk.js'; import { migrateAfk } from './afk.js';
import { migrateBankCredentials } from './bank-credentials.js'; import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js'; import { migrateTemperatures } from '../hosts/temperatures.js';
import { migrateModeration, migrateModerationCases } from '../moderation/schema.js'; import {
migrateModeration,
migrateModerationCases,
migrateModerationCaseActions
} from '../moderation/schema.js';
import type { Migration } from './migration-runner.js'; import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an // Append only: never renumber entries or change an applied migration to add an
@@ -170,7 +174,12 @@ export const migrations: readonly Migration[] = [
mode: 'foreign-keys-off', mode: 'foreign-keys-off',
run: migrateModerationCases run: migrateModerationCases
}, },
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk } { version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk },
{
version: 31,
name: 'moderation case links to existing actions',
run: migrateModerationCaseActions
}
]; ];
export const databaseVersion = migrations.length; export const databaseVersion = migrations.length;
+60 -2
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { archivedEvidenceSnapshots } from './evidence.js'; import { archivedEvidenceSnapshots } from './evidence.js';
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
import { AuthError } from '../auth/index.js'; import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import { import {
@@ -220,7 +221,13 @@ function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: st
.run(now, id); .run(now, id);
advancePolicy(); advancePolicy();
moderationEvent(actor, 'restriction.revoked', row, id, reason, now); moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
if (row.case_id) touchCase(row.case_id, now); db()
.prepare(
`UPDATE moderation_cases SET updated_at=?,revision=revision+1
WHERE id=? OR id IN (SELECT case_id FROM moderation_case_action_links
WHERE action_type='restriction' AND action_id=?)`
)
.run(now, row.case_id, id);
return { id }; return { id };
} }
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) { function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
@@ -465,6 +472,27 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
if (input.op === 'action') return liveAction(actor, input, now); if (input.op === 'action') return liveAction(actor, input, now);
if (input.op === 'create-case') return createCase(actor, input, now); if (input.op === 'create-case') return createCase(actor, input, now);
if (input.op === 'update-case') return updateCase(actor, input, now); if (input.op === 'update-case') return updateCase(actor, input, now);
if (input.op === 'link-case-action') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const reference = caseActionReference({ type: input.actionType, id: input.actionId });
const reason = textField(input.reason, 'Link reason', 4000);
recordedCaseAction(row, reference, now);
if (isCaseActionLinked(row.id, reference)) return { id: row.id };
db()
.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)')
.run(row.id, reference.type, reference.id, actor.username, now);
touchCase(row.id, now);
moderationEvent(
actor,
'case.action_linked',
row,
row.id,
JSON.stringify({ actionReference: reference, reason }),
now
);
return { id: row.id };
}
if (input.op === 'case-note') { if (input.op === 'case-note') {
const row = caseRow(input.id); const row = caseRow(input.id);
if (input.revision !== undefined) checkCaseRevision(row, input.revision); if (input.revision !== undefined) checkCaseRevision(row, input.revision);
@@ -481,13 +509,43 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
const outcome = moderationCaseOutcomes.find((status) => status === input.status); const outcome = moderationCaseOutcomes.find((status) => status === input.status);
if (!outcome) throw new AuthError('Choose a case outcome.'); if (!outcome) throw new AuthError('Choose a case outcome.');
const reason = textField(input.reason, 'Decision reason', 4000); const reason = textField(input.reason, 'Decision reason', 4000);
const reference =
input.actionReference === undefined || input.actionReference === null
? null
: caseActionReference(input.actionReference);
if (reference) {
const action = recordedCaseAction(row, reference, now);
if (!isCaseActionLinked(row.id, reference))
throw new AuthError(
'Link the recorded action to this case before citing it.',
409
);
if (
outcome === 'warning_issued' &&
(action.type !== 'action' ||
action.kind !== 'warn' ||
action.state !== 'confirmed')
)
throw new AuthError('Warning issued must reference a confirmed warning.');
if (outcome === 'restriction_applied' && action.type !== 'restriction')
throw new AuthError(
'Restriction applied must reference a recorded restriction.'
);
}
evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true); evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
db() db()
.prepare( .prepare(
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?' 'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
) )
.run(outcome, now, row.id); .run(outcome, now, row.id);
moderationEvent(actor, `case.${outcome}`, row, row.id, reason, now); moderationEvent(
actor,
`case.${outcome}`,
row,
row.id,
reference ? JSON.stringify({ reason, actionReference: reference }) : reason,
now
);
return { id: row.id }; return { id: row.id };
} }
if (input.op === 'reopen-case') { if (input.op === 'reopen-case') {
+92
View File
@@ -0,0 +1,92 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { textField } from './core.js';
import type { ModerationCaseAction, ModerationGame } from '../../moderation.js';
type Subject = { game: ModerationGame; guid: string };
export type CaseActionReference = Pick<ModerationCaseAction, 'type' | 'id'>;
export function caseActionReference(value: unknown): CaseActionReference {
if (!value || typeof value !== 'object') throw new AuthError('Choose a recorded action.');
const reference = value as Record<string, unknown>;
if (reference.type !== 'action' && reference.type !== 'restriction')
throw new AuthError('Choose a recorded warning, action, or restriction.');
return { type: reference.type, id: textField(reference.id, 'Action ID', 128) };
}
const restrictionColumns = `id,'restriction' AS type,kind,reason,actor,
created_at AS createdAt,expires_at AS expiresAt,
CASE WHEN revoked_at IS NOT NULL THEN 'revoked'
WHEN expires_at IS NOT NULL AND expires_at<=? THEN 'expired' ELSE 'active' END AS state`;
const actionColumns = `id,'action' AS type,kind,reason,actor,
created_at AS createdAt,expires_at AS expiresAt,state`;
/** Only actual records for this account can become case evidence. */
export function recordedCaseAction(
subject: Subject,
reference: CaseActionReference,
now: string
): ModerationCaseAction {
const handle = db();
const action =
reference.type === 'restriction'
? handle
.prepare(
`SELECT ${restrictionColumns} FROM moderation_sanctions
WHERE id=? AND guid=? AND (game=? OR scope='fleet')`
)
.get(now, reference.id, subject.guid, subject.game)
: handle
.prepare(
`SELECT ${actionColumns} FROM moderation_actions
WHERE id=? AND guid=? AND game=?`
)
.get(reference.id, subject.guid, subject.game);
if (!action)
throw new AuthError('The recorded action is unavailable for this case’s player.', 404);
return action as ModerationCaseAction;
}
export function isCaseActionLinked(caseId: string, reference: CaseActionReference): boolean {
return Boolean(
db()
.prepare(
`SELECT 1 FROM moderation_case_action_links
WHERE case_id=? AND action_type=? AND action_id=?
UNION ALL SELECT 1 FROM moderation_sanctions
WHERE ?='restriction' AND id=? AND case_id=? LIMIT 1`
)
.get(caseId, reference.type, reference.id, reference.type, reference.id, caseId)
);
}
export function caseActionLists(caseId: string, subject: Subject, now: string) {
const handle = db();
const records = handle
.prepare(
`SELECT ${restrictionColumns},
(case_id=? OR EXISTS (SELECT 1 FROM moderation_case_action_links l
WHERE l.case_id=? AND l.action_type='restriction' AND l.action_id=moderation_sanctions.id)) AS linked
FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet')
UNION ALL SELECT ${actionColumns},
EXISTS (SELECT 1 FROM moderation_case_action_links l
WHERE l.case_id=? AND l.action_type='action' AND l.action_id=moderation_actions.id) AS linked
FROM moderation_actions WHERE guid=? AND game=?
ORDER BY createdAt DESC,id`
)
.all(
now,
caseId,
caseId,
subject.guid,
subject.game,
caseId,
subject.guid,
subject.game
) as Array<ModerationCaseAction & { linked: number | null }>;
const linkedActions: ModerationCaseAction[] = [];
const availableActions: ModerationCaseAction[] = [];
for (const { linked, ...record } of records)
(linked ? linkedActions : availableActions).push(record);
return { linkedActions, availableActions };
}
+9 -4
View File
@@ -2,6 +2,7 @@ import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js'; import { AuthError } from '../auth/index.js';
import { moderationActor, textField } from './core.js'; import { moderationActor, textField } from './core.js';
import { moderationOverview } from './overview.js'; import { moderationOverview } from './overview.js';
import { caseActionLists } from './case-actions.js';
import type { import type {
ModerationCase, ModerationCase,
ModerationCaseDetail, ModerationCaseDetail,
@@ -42,9 +43,11 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
.prepare( .prepare(
`SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note, `SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note,
created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision
FROM moderation_sanctions WHERE case_id=? ORDER BY created_at DESC,id` FROM moderation_sanctions WHERE case_id=? OR id IN
(SELECT action_id FROM moderation_case_action_links WHERE case_id=? AND action_type='restriction')
ORDER BY created_at DESC,id`
) )
.all(caseId) as Omit<ModerationSanction, 'active'>[] .all(caseId, caseId) as Omit<ModerationSanction, 'active'>[]
).map((restriction) => ({ ).map((restriction) => ({
...restriction, ...restriction,
active: active:
@@ -55,9 +58,10 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
.prepare( .prepare(
`SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt `SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt
FROM moderation_events WHERE subject_id=? OR subject_id IN FROM moderation_events WHERE subject_id=? OR subject_id IN
(SELECT id FROM moderation_sanctions WHERE case_id=?) ORDER BY id DESC` (SELECT id FROM moderation_sanctions WHERE case_id=?) OR subject_id IN
(SELECT action_id FROM moderation_case_action_links WHERE case_id=?) ORDER BY id DESC`
) )
.all(caseId, caseId) as ModerationEvent[]; .all(caseId, caseId, caseId) as ModerationEvent[];
const assignees = handle const assignees = handle
.prepare( .prepare(
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator') `SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
@@ -77,6 +81,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
lastSeen: null lastSeen: null
}, },
sanctions, sanctions,
...caseActionLists(caseId, row, overview.checkedAt),
events, events,
coverage: overview.coverage, coverage: overview.coverage,
assignees, assignees,
+16
View File
@@ -149,3 +149,19 @@ export function migrateModerationCases(handle: Database.Database): void {
CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id); CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id);
`); `);
} }
/** Links record prior enforcement without changing its original case or issuing it again. */
export function migrateModerationCaseActions(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_action_links (
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
action_type TEXT NOT NULL CHECK(action_type IN ('action','restriction')),
action_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(case_id,action_type,action_id)
);
CREATE INDEX IF NOT EXISTS moderation_case_action_source
ON moderation_case_action_links(action_type,action_id);
`);
}
@@ -0,0 +1,161 @@
<script lang="ts">
import type { ModerationCaseAction } from '$lib/moderation.js';
import { moderationActionName } from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
import StatusBadge from '../primitives/StatusBadge.svelte';
let {
open = $bindable(false),
caseId,
revision,
actions,
onsubmitted
}: {
open?: boolean;
caseId: string;
revision: number;
actions: ModerationCaseAction[];
onsubmitted: () => void;
} = $props();
let selected = $state('');
let reason = $state('');
let busy = $state(false);
let error = $state('');
let uncertain = $state(false);
let pending: Record<string, unknown> | null = null;
const action = $derived(actions.find((item) => `${item.type}:${item.id}` === selected));
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending) {
if (!action || !reason.trim()) {
error = 'Select an existing action and explain its connection to this case.';
return;
}
pending = {
op: 'link-case-action',
id: caseId,
revision,
actionType: action.type,
actionId: action.id,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
throw new Error(result?.error || 'Could not link the existing action.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error = cause instanceof Error ? cause.message : 'Could not link the existing action.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title="Link existing action"
description="Connect a previously recorded action to this case. Linking preserves its original date and outcome without issuing it again."
{busy}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field label="Existing action" required>
<Select
bind:value={selected}
required
options={[
{ value: '', label: 'Select an action' },
...actions.map((item) => ({
value: `${item.type}:${item.id}`,
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
}))
]}
/>
</Field>
{#if action}<div class="action-detail">
<strong>{moderationActionName(action.kind)}</strong>
<StatusBadge state={`moderation-${action.state}`} />
<p>{action.reason}</p>
<p class="hint">
Recorded by {action.actor} · {$timeFormat.dateTime(action.createdAt)}
</p>
{#if action.type === 'restriction'}<p class="hint">
{action.expiresAt
? `Expires ${$timeFormat.dateTime(action.expiresAt)}`
: 'Permanent restriction'}
</p>{/if}
</div>{/if}
<Field label="Link reason" required hint="Explain how this action relates to the case."
><Textarea bind:value={reason} rows={3} maxlength={4000} required /></Field
>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same request safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}>{uncertain ? 'Retry same request' : 'Link action'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
.action-detail {
padding: var(--space-3);
background: var(--surface-sunken);
border-radius: var(--radius-md);
}
.action-detail p {
margin: var(--space-2) 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
</style>
@@ -7,9 +7,11 @@
type ModerationCoverage, type ModerationCoverage,
type ModerationGame, type ModerationGame,
type ModerationScope, type ModerationScope,
type ModerationCase type ModerationCase,
type ModerationCaseAction
} from '$lib/moderation.js'; } from '$lib/moderation.js';
import { moderationGameName } from '$ui/moderation.js'; import { moderationActionName, moderationGameName } from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import Dialog from '../primitives/Dialog.svelte'; import Dialog from '../primitives/Dialog.svelte';
@@ -26,6 +28,7 @@
admin, admin,
seed = {}, seed = {},
assignees = [], assignees = [],
linkedActions = [],
onsubmitted onsubmitted
}: { }: {
open?: boolean; open?: boolean;
@@ -43,6 +46,7 @@
coverage: ModerationCoverage[]; coverage: ModerationCoverage[];
admin: boolean; admin: boolean;
assignees?: { id: string; username: string }[]; assignees?: { id: string; username: string }[];
linkedActions?: ModerationCaseAction[];
seed?: { seed?: {
game?: ModerationGame; game?: ModerationGame;
guid?: string; guid?: string;
@@ -81,6 +85,21 @@
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length); const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action'); const gameText = $derived(mode === 'restriction' || mode === 'action');
let resolution = $state('resolved'); let resolution = $state('resolved');
let actionReference = $state('');
const referenceActions = $derived(
linkedActions.filter((item) =>
resolution === 'warning_issued'
? item.type === 'action' && item.kind === 'warn' && item.state === 'confirmed'
: resolution === 'restriction_applied' && item.type === 'restriction'
)
);
const referencedAction = $derived(
referenceActions.find((item) => `${item.type}:${item.id}` === actionReference)
);
$effect(() => {
void resolution;
actionReference = '';
});
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let uncertain = $state(false); let uncertain = $state(false);
@@ -203,7 +222,10 @@
id: seed.id, id: seed.id,
revision: seed.revision, revision: seed.revision,
status: resolution, status: resolution,
reason: reason.trim() reason: reason.trim(),
...(referencedAction
? { actionReference: { type: referencedAction.type, id: referencedAction.id } }
: {})
}; };
} }
@@ -468,6 +490,29 @@
separately, and send warnings through Live action. Choosing an outcome does not separately, and send warnings through Live action. Choosing an outcome does not
change enforcement or send a warning. change enforcement or send a warning.
</p>{/if} </p>{/if}
{#if mode === 'resolve' && ['warning_issued', 'restriction_applied'].includes(resolution)}
<Field
label="Supporting action"
hint="Optional. Reference an existing linked action as the basis for this outcome."
>
<Select
bind:value={actionReference}
options={[
{ value: '', label: 'No action reference' },
...referenceActions.map((item) => ({
value: `${item.type}:${item.id}`,
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
}))
]}
/>
</Field>
{#if !referenceActions.length}<p class="hint">
{resolution === 'warning_issued'
? 'No confirmed warnings are linked to this case.'
: 'No restrictions are linked to this case.'} Use “Link existing action” on
the case to add a prior action.
</p>{/if}
{/if}
{#if mode === 'reopen'}<p class="hint"> {#if mode === 'reopen'}<p class="hint">
Reopening returns the case to Open and preserves its evidence, restrictions, and Reopening returns the case to Open and preserves its evidence, restrictions, and
earlier decisions. earlier decisions.
+20
View File
@@ -70,10 +70,18 @@ export const moderationActionLabels: Record<string, string> = {
'case.updated': 'Case updated', 'case.updated': 'Case updated',
'case.evidence_attached': 'Evidence attached', 'case.evidence_attached': 'Evidence attached',
'case.restriction': 'Restriction linked to case', 'case.restriction': 'Restriction linked to case',
'case.action_linked': 'Existing action linked to case',
'link-case-action': 'Existing action linked to case',
'player.note': 'Private player note', 'player.note': 'Private player note',
'runtime.configure': 'Integration configuration requested' 'runtime.configure': 'Integration configuration requested'
}; };
export function moderationActionName(kind: string): string {
return (
moderationActionLabels[kind] ?? (kind === 'ban' ? 'Ban' : kind === 'mute' ? 'Mute' : kind)
);
}
export function moderationEventDetail(detail: string, action: string): string { export function moderationEventDetail(detail: string, action: string): string {
if (action.startsWith('case.')) { if (action.startsWith('case.')) {
try { try {
@@ -110,6 +118,18 @@ export function moderationEventDetail(detail: string, action: string): string {
return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`; return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`;
if (action === 'case.restriction' && typeof value.reason === 'string') if (action === 'case.restriction' && typeof value.reason === 'string')
return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`; return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`;
if (
typeof value.reason === 'string' &&
value.actionReference &&
typeof value.actionReference === 'object'
) {
const reference = value.actionReference as { type?: unknown; id?: unknown };
if (
(reference.type === 'restriction' || reference.type === 'action') &&
typeof reference.id === 'string'
)
return `${value.reason}\nReferenced ${reference.type === 'restriction' ? 'restriction' : 'action'}: ${reference.id}`;
}
} catch { } catch {
// Older case events and decision reasons are plain staff-authored text. // Older case events and decision reasons are plain staff-authored text.
} }
+61 -5
View File
@@ -12,6 +12,7 @@
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { import {
moderationActionLabels, moderationActionLabels,
moderationActionName,
moderationEventDetail, moderationEventDetail,
moderationGameName, moderationGameName,
moderationHref moderationHref
@@ -29,6 +30,7 @@
import ServerIdentity from '$ui/components/ServerIdentity.svelte'; import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte'; import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte'; import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte';
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
import { preservedConversation } from '$ui/moderation-evidence.js'; import { preservedConversation } from '$ui/moderation-evidence.js';
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props(); let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
@@ -55,6 +57,7 @@
let generation = 0; let generation = 0;
let controller: AbortController | undefined; let controller: AbortController | undefined;
let dialogOpen = $state(false); let dialogOpen = $state(false);
let linkActionOpen = $state(false);
let dialogKey = $state(0); let dialogKey = $state(0);
let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note'); let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note');
let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({}); let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({});
@@ -67,6 +70,7 @@
refreshError = ''; refreshError = '';
notice = ''; notice = '';
dialogOpen = false; dialogOpen = false;
linkActionOpen = false;
updatedAt = Date.parse(initial.checkedAt); updatedAt = Date.parse(initial.checkedAt);
}); });
}); });
@@ -149,6 +153,12 @@
cancelRefresh(); cancelRefresh();
void refresh(); void refresh();
} }
function actionLinked() {
notice =
'Existing action linked to this case. Its original date and outcome are preserved.';
cancelRefresh();
void refresh();
}
function archiveContext(raw: string | null | undefined) { function archiveContext(raw: string | null | undefined) {
if (!raw) return null; if (!raw) return null;
@@ -218,8 +228,8 @@
</SectionHeader> </SectionHeader>
{#if !permitted}<p class="notice"> {#if !permitted}<p class="notice">
Read-only moderation access. You can inspect this case, its evidence, and linked Read-only moderation access. You can inspect this case, its evidence, and linked actions
restrictions. and restrictions.
</p>{/if} </p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if} {#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if refreshError}<div class="ui-error" role="alert"> {#if refreshError}<div class="ui-error" role="alert">
@@ -400,6 +410,45 @@
> >
</section> </section>
<section class="panel" aria-labelledby="actions-title">
<div class="section-heading">
<h2 id="actions-title">Linked actions</h2>
{#if permitted}<Button
size="sm"
disabled={!report.availableActions?.length}
onclick={() => (linkActionOpen = true)}>Link existing action</Button
>{/if}
</div>
{#if report.linkedActions?.length}<ol class="records">
{#each report.linkedActions as action (`${action.type}:${action.id}`)}<li
class="record"
>
<div class="row">
<strong>{moderationActionName(action.kind)}</strong><StatusBadge
state={`moderation-${action.state}`}
/>
</div>
<p class="record-text">{action.reason}</p>
<p class="hint">
{action.actor} · <RelativeTime value={action.createdAt} />
</p>
<CopyValue
value={action.id}
label={action.type === 'restriction'
? 'Restriction ID'
: 'Action ID'}
/>
</li>{/each}
</ol>{:else}<p class="hint">
No existing actions are linked to this case.
</p>{/if}
{#if permitted}<p class="hint">
{report.availableActions?.length
? `${report.availableActions.length} previous actions available for this player.`
: 'No additional recorded actions are available for this player.'}
</p>{/if}
</section>
<section class="panel" aria-labelledby="restrictions-title"> <section class="panel" aria-labelledby="restrictions-title">
<div class="section-heading"> <div class="section-heading">
<h2 id="restrictions-title">Linked restrictions</h2> <h2 id="restrictions-title">Linked restrictions</h2>
@@ -466,9 +515,7 @@
})}>Revoke restriction</Button })}>Revoke restriction</Button
>{/if} >{/if}
</li>{/each} </li>{/each}
</ol>{:else}<p class="hint"> </ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
No restrictions have been applied from this case.
</p>{/if}
<p class="hint"> <p class="hint">
Applying or revoking a restriction does not change the case outcome. Applying or revoking a restriction does not change the case outcome.
</p> </p>
@@ -506,9 +553,18 @@
{admin} {admin}
coverage={report.coverage} coverage={report.coverage}
assignees={report.assignees} assignees={report.assignees}
linkedActions={report.linkedActions}
onsubmitted={submitted} onsubmitted={submitted}
/>{/key}{/if} />{/key}{/if}
{#if permitted && linkActionOpen}<LinkCaseActionDialog
bind:open={linkActionOpen}
caseId={item.id}
revision={item.revision}
actions={report.availableActions ?? []}
onsubmitted={actionLinked}
/>{/if}
<style> <style>
.case-page, .case-page,
.case-main, .case-main,
+118 -1
View File
@@ -216,7 +216,8 @@ it('preserves original evidence context, linked restrictions, and staff history
'Restrict player', 'Restrict player',
'Revoke restriction', 'Revoke restriction',
'Add case note', 'Add case note',
'Attach evidence' 'Attach evidence',
'Link existing action'
]) ])
expect(button(label)).toBeUndefined(); expect(button(label)).toBeUndefined();
expect(document.querySelector('dialog')).toBeNull(); expect(document.querySelector('dialog')).toBeNull();
@@ -380,6 +381,122 @@ it('attaches archived evidence by match and exact message with revision checks',
}); });
}); });
it('links a prior mute with its recorded outcome without issuing another restriction', async () => {
const initial = detail();
const prior = {
id: 'earlier-mute',
type: 'restriction' as const,
kind: 'mute',
reason: 'Already applied before the report',
actor: 'Moderator Two',
createdAt: '2026-10-14T10:00:00Z',
state: 'expired',
expiresAt: '2026-10-14T11:00:00Z'
};
initial.availableActions = [prior];
initial.linkedActions = [];
const updated = detail();
updated.linkedActions = [prior];
updated.availableActions = [];
updated.case.revision = 5;
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValue(response(updated));
vi.stubGlobal('fetch', fetch);
show(initial);
button('Link existing action').click();
await tick();
select('Existing action', 'restriction:earlier-mute');
expect(document.querySelector('dialog[open]')?.textContent).toContain('Expired');
expect(document.querySelector('dialog[open]')?.textContent).toContain('Moderator Two');
enter(field('Link reason'), 'This mute addressed the reported chat');
button('Link action').click();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'link-case-action',
id: 'case-1',
revision: 4,
actionType: 'restriction',
actionId: 'earlier-mute',
reason: 'This mute addressed the reported chat',
idempotencyKey: expect.any(String)
});
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
'Already applied before the report'
);
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
'Expired'
);
expect(button('Link existing action').disabled).toBe(true);
});
it.each([
['warning_issued', 'action:warning-1', 'action', 'warning-1'],
['restriction_applied', 'restriction:restriction-1', 'restriction', 'restriction-1']
] as const)(
'references a linked action when recording %s',
async (outcome, selection, type, id) => {
const initial = detail();
initial.linkedActions = [
{
id: 'warning-1',
type: 'action',
kind: 'warn',
reason: 'Confirmed warning',
actor: 'Moderator One',
createdAt: '2026-10-15T10:00:00Z',
state: 'confirmed',
expiresAt: null
},
{
id: 'failed-warning',
type: 'action',
kind: 'warn',
reason: 'Undelivered warning',
actor: 'Moderator One',
createdAt: '2026-10-15T09:00:00Z',
state: 'failed',
expiresAt: null
},
{
id: 'restriction-1',
type: 'restriction',
kind: 'mute',
reason: 'Prior mute',
actor: 'Moderator One',
createdAt: '2026-10-15T11:00:00Z',
state: 'expired',
expiresAt: '2026-10-15T12:00:00Z'
}
];
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValue(response(initial));
vi.stubGlobal('fetch', fetch);
show(initial);
button('Review case').click();
await tick();
select('Outcome', outcome);
const options = [...field<HTMLSelectElement>('Supporting action').options].map(
(option) => option.value
);
expect(options).toEqual(['', selection]);
select('Supporting action', selection);
enter(field('Review reason'), 'Reviewed the recorded action');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'resolve-case',
id: 'case-1',
revision: 4,
status: outcome,
actionReference: { type, id }
});
}
);
it('retains a conflicted review and lets staff refresh before making another decision', async () => { it('retains a conflicted review and lets staff refresh before making another decision', async () => {
const updated = detail(); const updated = detail();
updated.case.revision = 8; updated.case.revision = 8;
+331
View File
@@ -0,0 +1,331 @@
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'moderator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
const create = (overrides: Record<string, unknown> = {}) =>
mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct', ...overrides });
const restriction = (overrides: Record<string, unknown> = {}, actor = 'moderator') =>
mutate(
{
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'mute',
scope: 'game',
durationMinutes: 60,
reason: 'Earlier moderation action',
...overrides
},
actor
);
function action(overrides: Record<string, string | null> = {}) {
const record = {
id: randomUUID(),
game: 't5',
guid: '123',
kind: 'warn',
state: 'confirmed',
...overrides
};
handle
.prepare(
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,
kind,reason,actor_id,actor,created_at,expires_at,state)
VALUES(?,?,?,'retired-server','retired-instance',1,'old-session',?,
'Earlier warning','moderator','Former moderator',?,?,?)`
)
.run(record.id, record.game, record.guid, record.kind, clock, clock, record.state);
handle
.prepare(
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
VALUES(?,?,'Former moderator',?,?,'Recorded delivery result',?)`
)
.run(record.game, record.guid, `action.${record.state}`, record.id, clock);
return record.id as string;
}
function enforcement() {
return {
sanctions: handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all(),
actions: handle.prepare('SELECT * FROM moderation_actions ORDER BY id').all(),
policy: handle.prepare('SELECT * FROM moderation_policy').all()
};
}
const link = (id: string, revision: number, actionType: string, actionId: string) => ({
op: 'link-case-action',
id,
revision,
actionType,
actionId,
reason: 'This earlier action addresses the incident under review.'
});
it('links a prior restriction to multiple cases without changing enforcement or its original case', async () => {
const original = await create();
const sanction = await restriction({ caseId: original.id, caseRevision: 1 });
const current = await create();
const before = enforcement();
const request = {
...link(current.id, 1, 'restriction', sanction.id),
idempotencyKey: randomUUID()
};
await mutate(request);
await mutate(request);
let data = await detail(current.id);
expect(data.case.revision).toBe(2);
expect(data.sanctions).toMatchObject([{ id: sanction.id, caseId: original.id }]);
expect(data.linkedActions).toMatchObject([
{ id: sanction.id, type: 'restriction', state: 'active' }
]);
expect(data.availableActions).toEqual([]);
expect(data.events.map((event) => event.action)).toContain('restriction.created');
expect((await detail(original.id)).linkedActions).toMatchObject([{ id: sanction.id }]);
expect(enforcement()).toEqual(before);
await mutate(link(current.id, 2, 'restriction', sanction.id));
expect(await detail(current.id)).toEqual(data);
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_action_links').get()
).toEqual({ count: 1 });
const event = data.events.find((entry) => entry.action === 'case.action_linked')!;
expect(JSON.parse(event.detail)).toEqual({
reason: request.reason,
actionReference: { type: 'restriction', id: sanction.id }
});
await mutate({
op: 'revoke-sanction',
id: sanction.id,
revision: 1,
reason: 'Review completed'
});
data = await detail(current.id);
expect(data.case.revision).toBe(3);
expect((await detail(original.id)).case.revision).toBe(3);
expect(data.linkedActions?.[0].state).toBe('revoked');
expect(data.events[0].action).toBe('restriction.revoked');
});
it('links historical live actions to closed cases and exposes their real delivery state and timeline', async () => {
const warningId = action();
const failedId = action({ state: 'failed' });
action({ game: 't6' });
action({ guid: '456' });
action({ guid: null, kind: 'announce' });
const { id } = await create();
expect((await detail(id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
[warningId, failedId].sort()
);
await mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'no_action',
reason: 'Initial review complete'
});
const before = enforcement();
await mutate(link(id, 2, 'action', warningId));
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ status: 'no_action', revision: 3 });
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
expect(enforcement()).toEqual(before);
await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
});
it('rejects unauthorized, stale, missing and wrong-player or game links and permits a matching fleet restriction', async () => {
const { id } = await create();
const right = action();
const otherPlayer = action({ guid: '456' });
const otherGame = action({ game: 't6' });
const wrongRestriction = await restriction({ guid: '456' });
for (const actor of ['super_viewer', 'viewer'])
await expect(mutate(link(id, 1, 'action', right), actor)).rejects.toMatchObject({
status: 403
});
await expect(mutate(link(id, 2, 'action', right))).rejects.toMatchObject({ status: 409 });
for (const [type, source] of [
['action', otherPlayer],
['action', otherGame],
['action', 'missing'],
['restriction', wrongRestriction.id]
])
await expect(mutate(link(id, 1, type, source))).rejects.toMatchObject({ status: 404 });
expect((await detail(id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
for (const game of ['t5', 't6'])
handle
.prepare('INSERT INTO moderation_aliases VALUES(?,?,?,?,?)')
.run(game, '123', 'Player', clock, clock);
const fleet = await restriction({ scope: 'fleet' }, 'admin');
const t6 = await create({ game: 't6' });
expect((await detail(t6.id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
[fleet.id, otherGame].sort()
);
await mutate(link(t6.id, 1, 'restriction', fleet.id));
expect((await detail(t6.id)).sanctions).toMatchObject([
{ id: fleet.id, scope: 'fleet', game: 't5' }
]);
});
it('requires a linked confirmed warning for a cited warning outcome and preserves the actual reference', async () => {
const { id } = await create();
const confirmed = action();
const failed = action({ state: 'failed' });
const kick = action({ kind: 'kick' });
const sanction = await restriction();
const resolve = (
revision: number,
type: string,
actionId: string,
status = 'warning_issued'
) => ({
op: 'resolve-case',
id,
revision,
status,
reason: 'Decision after evidence review',
actionReference: { type, id: actionId }
});
await expect(mutate(resolve(1, 'action', confirmed))).rejects.toThrow(
'Link the recorded action'
);
await mutate(link(id, 1, 'action', failed));
await mutate(link(id, 2, 'action', kick));
await mutate(link(id, 3, 'restriction', sanction.id));
for (const [type, source] of [
['action', failed],
['action', kick],
['restriction', sanction.id]
])
await expect(mutate(resolve(4, type, source))).rejects.toThrow('confirmed warning');
await mutate(link(id, 4, 'action', confirmed));
await expect(mutate(resolve(5, 'action', confirmed, 'restriction_applied'))).rejects.toThrow(
'recorded restriction'
);
const before = enforcement();
await mutate(resolve(5, 'action', confirmed));
const data = await detail(id);
expect(data.case).toMatchObject({ status: 'warning_issued', revision: 6 });
expect(JSON.parse(data.events[0].detail)).toEqual({
reason: 'Decision after evidence review',
actionReference: { type: 'action', id: confirmed }
});
expect(enforcement()).toEqual(before);
});
it('allows a historical expired restriction created from the original case to support its closure', async () => {
const { id } = await create();
const sanction = await restriction({ caseId: id, caseRevision: 1 });
vi.setSystemTime(new Date(Date.parse(clock) + 120 * 60_000));
expect((await detail(id)).linkedActions).toMatchObject([{ id: sanction.id, state: 'expired' }]);
const before = enforcement();
await mutate({
op: 'resolve-case',
id,
revision: 2,
status: 'restriction_applied',
reason: 'Earlier mute addressed the incident',
actionReference: { type: 'restriction', id: sanction.id }
});
expect((await detail(id)).case.status).toBe('restriction_applied');
expect(enforcement()).toEqual(before);
});
it('rolls back a link, case revision and idempotency record if its audit write fails', async () => {
const { id } = await create();
const warning = action();
const before = await detail(id);
handle.exec(`CREATE TRIGGER reject_case_action_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.action_linked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const request = { ...link(id, 1, 'action', warning), idempotencyKey: randomUUID() };
await expect(mutate(request)).rejects.toThrow('audit unavailable');
expect(await detail(id)).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
handle.exec('DROP TRIGGER reject_case_action_audit');
await mutate(request);
expect((await detail(id)).linkedActions).toMatchObject([{ id: warning }]);
});
it('upgrades existing databases without changing original case restrictions and enforces link identity', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 30));
legacy
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('case','t5','123','Prior case','open','former','Former staff',?,?)`
)
.run(clock, clock);
legacy
.prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
VALUES('restriction','t5','123','mute','game','Prior restriction','','former','Former staff',?,'case')`
)
.run(clock);
const before = legacy.prepare('SELECT * FROM moderation_sanctions').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_sanctions').all()).toEqual(before);
const insert = legacy.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)');
insert.run('case', 'restriction', 'restriction', 'Moderator', clock);
expect(() => insert.run('case', 'restriction', 'restriction', 'Moderator', clock)).toThrow(
'UNIQUE'
);
expect(() => insert.run('missing', 'action', 'action', 'Moderator', clock)).toThrow(
'FOREIGN KEY'
);
expect(() => insert.run('case', 'other', 'action', 'Moderator', clock)).toThrow('CHECK');
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});