Expand moderation case workflows
CI / checks (push) Successful in 6m49s

This commit is contained in:
2026-10-17 16:19:33 -04:00
parent 4501eba873
commit 7f2205e9c4
22 changed files with 2815 additions and 167 deletions
+60 -2
View File
@@ -6,6 +6,41 @@ export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
export type ModerationState =
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
export const moderationCaseOutcomes = [
'resolved',
'dismissed',
'warning_issued',
'restriction_applied',
'no_action',
'duplicate',
'appeal_upheld',
'appeal_denied'
] as const;
export const moderationCaseStatuses = [
'open',
'in_review',
'escalated',
...moderationCaseOutcomes
] as const;
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = {
open: 'Open',
in_review: 'In review',
escalated: 'Escalated',
resolved: 'Resolved',
dismissed: 'Dismissed',
warning_issued: 'Warning issued',
restriction_applied: 'Restriction applied',
no_action: 'No action needed',
duplicate: 'Duplicate',
appeal_upheld: 'Appeal upheld',
appeal_denied: 'Appeal denied'
};
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
return status === 'open' || status === 'in_review' || status === 'escalated';
}
export interface ModerationPlayer {
game: ModerationGame;
guid: string;
@@ -18,6 +53,7 @@ export interface ModerationPlayer {
}
export interface ModerationSanction {
id: string;
caseId?: string | null;
game: ModerationGame;
guid: string;
kind: 'ban' | 'mute';
@@ -68,12 +104,31 @@ export interface ModerationCase {
game: ModerationGame;
guid: string;
title: string;
status: 'open' | 'resolved' | 'dismissed';
status: ModerationCaseStatus;
priority: ModerationCasePriority;
assigneeId: string | null;
assignee: string | null;
actor: string;
createdAt: string;
updatedAt: string;
revision: number;
evidence: { id: string; text: string; source: string; createdAt: string; actor: string }[];
evidence: {
id: string;
text: string;
source: string;
snapshot?: string | null;
createdAt: string;
actor: string;
}[];
}
export interface ModerationCaseDetail {
case: ModerationCase;
player: ModerationPlayer;
sanctions: ModerationSanction[];
events: ModerationEvent[];
coverage: ModerationCoverage[];
assignees: { id: string; username: string }[];
checkedAt: string;
}
export interface ModerationEvent {
id: number;
@@ -100,6 +155,9 @@ export interface ModerationQuery {
game: '' | ModerationGame;
guid: string;
offset: number;
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
priority?: '' | ModerationCasePriority;
assignedTo?: string;
}
export interface ModerationOverview {
query: ModerationQuery;
+8 -2
View File
@@ -27,7 +27,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js';
import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js';
import { migrateModeration } from '../moderation/schema.js';
import { migrateModeration, migrateModerationCases } from '../moderation/schema.js';
import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an
@@ -162,7 +162,13 @@ export const migrations: readonly Migration[] = [
);
}
},
{ version: 28, name: 'known login devices', run: migrateLoginDevices }
{ version: 28, name: 'known login devices', run: migrateLoginDevices },
{
version: 29,
name: 'moderation case workflow and linked restrictions',
mode: 'foreign-keys-off',
run: migrateModerationCases
}
];
export const databaseVersion = migrations.length;
+242 -63
View File
@@ -4,7 +4,12 @@ import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import {
canRevokeModerationRestriction,
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatusLabels,
moderationGuid,
type ModerationCaseStatus,
type ModerationCasePriority,
type ModerationGame,
type ModerationScope
} from '../../moderation.js';
@@ -34,8 +39,18 @@ type SanctionRow = {
expires_at: string | null;
revision: number;
revoked_at: string | null;
case_id: string | null;
};
type CaseRow = {
id: string;
game: ModerationGame;
guid: string;
revision: number;
status: ModerationCaseStatus;
title: string;
priority: ModerationCasePriority;
assignee_id: string | null;
};
type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: string };
function serverTarget(name: unknown, game?: ModerationGame) {
const server = textField(name, 'Server', 255);
@@ -61,6 +76,15 @@ function serverTarget(name: unknown, game?: ModerationGame) {
function createSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
const target = subject(input);
const linkedCase =
input.caseId === undefined || input.caseId === null ? null : caseRow(input.caseId);
if (linkedCase) {
checkCaseRevision(linkedCase, input.caseRevision);
if (!isModerationCaseOpen(linkedCase.status))
throw new AuthError('Reopen this case before applying a restriction.', 409);
if (linkedCase.game !== target.game || linkedCase.guid !== target.guid)
throw new AuthError('A case restriction must target the case’s game and player.');
}
if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.');
if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope))
throw new AuthError('Choose a restriction scope.');
@@ -117,8 +141,8 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
ensureSubject(target.game, target.guid);
db()
.prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at,case_id)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
id,
@@ -133,7 +157,8 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
actor.id,
actor.username,
now,
expires
expires,
linkedCase?.id ?? null
);
advancePolicy();
moderationEvent(
@@ -147,10 +172,22 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
server: server?.name ?? null,
reason,
expiresAt: expires,
note
note,
caseId: linkedCase?.id ?? null
}),
now
);
if (linkedCase) {
touchCase(linkedCase.id, now);
moderationEvent(
actor,
'case.restriction',
linkedCase,
linkedCase.id,
JSON.stringify({ restrictionId: id, kind: input.kind, scope: input.scope, reason }),
now
);
}
return { id };
}
function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
@@ -183,6 +220,7 @@ function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: st
.run(now, id);
advancePolicy();
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
if (row.case_id) touchCase(row.case_id, now);
return { id };
}
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
@@ -267,15 +305,43 @@ function caseRow(value: unknown): CaseRow {
if (!row) throw new AuthError('Case not found.', 404);
return row;
}
function checkCaseRevision(row: CaseRow, revision: unknown) {
if (row.revision !== revision)
throw new AuthError('This case changed. Refresh before trying again.', 409);
}
function touchCase(id: string, now: string) {
db()
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
.run(now, id);
}
function casePriority(value: unknown): ModerationCasePriority {
if (value !== 'low' && value !== 'normal' && value !== 'high' && value !== 'urgent')
throw new AuthError('Choose low, normal, high, or urgent priority.');
return value;
}
function caseAssignee(value: unknown): { id: string | null; username: string | null } {
if (value === null) return { id: null, username: null };
const id = textField(value, 'Assignee', 128);
const user = db()
.prepare(
`SELECT id,username FROM auth_users
WHERE id=? AND role IN ('admin','moderator') AND disabled=0 AND must_change_password=0`
)
.get(id) as { id: string; username: string } | undefined;
if (!user) throw new AuthError('Choose an active administrator or moderator to own this case.');
return user;
}
function evidence(
actor: AuthUser,
id: string,
text: string,
source: string,
snapshot: string | null,
now: string
now: string,
decision = false
) {
if (
!decision &&
(
db()
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
@@ -291,93 +357,206 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
const target = subject(input);
const title = textField(input.title, 'Case title', 160);
const note = textField(input.note, 'Case note', 4000, true);
const priority = input.priority === undefined ? 'normal' : casePriority(input.priority);
const assignee =
input.assigneeId === undefined
? { id: null, username: null }
: caseAssignee(input.assigneeId);
const id = randomUUID();
ensureSubject(target.game, target.guid);
db()
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,?,?,?,'open',?,?,?,?)`
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,priority,assignee_id) VALUES(?,?,?,?,'open',?,?,?,?,?,?)`
)
.run(id, target.game, target.guid, title, actor.id, actor.username, now, now);
.run(
id,
target.game,
target.guid,
title,
actor.id,
actor.username,
now,
now,
priority,
assignee.id
);
if (note) evidence(actor, id, note, 'Staff note', null, now);
if (input.evidence !== undefined) {
const requested = input.evidence as { matchId: unknown; message: unknown };
if (!requested || !Number.isSafeInteger(requested.matchId))
throw new AuthError('Choose a recorded chat message.');
const message = textField(requested.message, 'Chat message', 4096);
const match = db()
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
.get(requested.matchId) as
{ game: string; server: string; started_at: string } | undefined;
if (!match || match.game !== target.game)
throw new AuthError('The source match is unavailable.', 404);
const entries = db()
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
.all(requested.matchId) as { entry: string }[];
const found = entries
.filter((row) => {
const entry = JSON.parse(row.entry);
return (
entry.type === 'message' &&
moderationGuid(target.game, entry.message.guid) === target.guid &&
entry.message.message === message
);
})
.slice(0, 10);
if (!found.length)
throw new AuthError(
'The original chat message could not be verified in the archive.',
409
);
for (const row of found)
evidence(
actor,
id,
message,
`Archived chat · ${match.server} · match ${requested.matchId}`,
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
now
);
}
if (input.evidence !== undefined) archivedEvidence(actor, id, target, input.evidence, now);
moderationEvent(actor, 'case.opened', target, id, title, now);
if (priority !== 'normal' || assignee.id)
moderationEvent(
actor,
'case.updated',
target,
id,
JSON.stringify({
priority: { from: 'normal', to: priority },
assignee: { from: null, to: assignee.username }
}),
now
);
return { id };
}
function archivedEvidence(
actor: AuthUser,
id: string,
target: { game: ModerationGame; guid: string },
value: unknown,
now: string
): number {
const requested = value as { matchId: unknown; message: unknown };
if (!requested || !Number.isSafeInteger(requested.matchId))
throw new AuthError('Choose a recorded chat message.');
textField(requested.message, 'Chat message', 4096);
// Evidence must match the archived bytes, including intentional whitespace.
const message = requested.message as string;
const match = db()
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
.get(requested.matchId) as { game: string; server: string; started_at: string } | undefined;
if (!match || match.game !== target.game)
throw new AuthError('The source match is unavailable.', 404);
const entries = db()
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
.all(requested.matchId) as { entry: string }[];
const found = entries
.filter((row) => {
const entry = JSON.parse(row.entry);
return (
entry.type === 'message' &&
moderationGuid(target.game, entry.message.guid) === target.guid &&
entry.message.message === message
);
})
.slice(0, 10);
if (!found.length)
throw new AuthError('The original chat message could not be verified in the archive.', 409);
for (const row of found)
evidence(
actor,
id,
message,
`Archived chat · ${match.server} · match ${requested.matchId}`,
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
now
);
return found.length;
}
function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string) {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const title = input.title === undefined ? row.title : textField(input.title, 'Case title', 160);
const priority = input.priority === undefined ? row.priority : casePriority(input.priority);
const assignee =
input.assigneeId === undefined || input.assigneeId === row.assignee_id
? null
: caseAssignee(input.assigneeId);
const assigneeId = assignee ? assignee.id : row.assignee_id;
const status = input.status === undefined ? row.status : input.status;
if (
input.status !== undefined &&
status !== 'open' &&
status !== 'in_review' &&
status !== 'escalated'
)
throw new AuthError('Choose an active case status, or close the case with an outcome.');
if (input.status !== undefined && !isModerationCaseOpen(row.status))
throw new AuthError('Use reopen to reactivate a closed case.', 409);
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
const changes: Record<string, unknown> = {};
if (title !== row.title) changes.title = { from: row.title, to: title };
if (priority !== row.priority) changes.priority = { from: row.priority, to: priority };
if (status !== row.status) changes.status = { from: row.status, to: status };
if (assigneeId !== row.assignee_id) {
const previous = row.assignee_id
? (db().prepare('SELECT username FROM auth_users WHERE id=?').get(row.assignee_id) as
{ username: string } | undefined)
: null;
changes.assignee = { from: previous?.username ?? null, to: assignee?.username ?? null };
changes.assigneeId = { from: row.assignee_id, to: assigneeId };
}
if (!Object.keys(changes).length) throw new AuthError('Change the case details before saving.');
db()
.prepare(
`UPDATE moderation_cases SET title=?,priority=?,assignee_id=?,status=?,updated_at=?,revision=revision+1 WHERE id=?`
)
.run(title, priority, assigneeId, status, now, row.id);
moderationEvent(
actor,
'case.updated',
row,
row.id,
JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }),
now
);
return { id: row.id };
}
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
return moderationMutation(actorId, input, (actor, now) => {
if (input.op === 'create-sanction') return createSanction(actor, input, now);
if (input.op === 'revoke-sanction') return revokeSanction(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 === 'update-case') return updateCase(actor, input, now);
if (input.op === 'case-note') {
const row = caseRow(input.id);
if (input.revision !== undefined) checkCaseRevision(row, input.revision);
const text = textField(input.text, 'Note', 4000);
evidence(actor, row.id, text, 'Staff note', null, now);
db()
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
.run(now, row.id);
touchCase(row.id, now);
moderationEvent(actor, 'case.note', row, row.id, text, now);
return { id: row.id };
}
if (input.op === 'resolve-case') {
const row = caseRow(input.id);
if (row.revision !== input.revision || row.status !== 'open')
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
throw new AuthError('This case changed. Refresh before resolving it.', 409);
if (input.status !== 'resolved' && input.status !== 'dismissed')
throw new AuthError('Choose resolved or dismissed.');
const outcome = moderationCaseOutcomes.find((status) => status === input.status);
if (!outcome) throw new AuthError('Choose a case outcome.');
const reason = textField(input.reason, 'Decision reason', 4000);
evidence(
actor,
row.id,
reason,
input.status === 'resolved' ? 'Resolution' : 'Dismissal',
null,
now
);
evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
db()
.prepare(
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
)
.run(input.status, now, row.id);
moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now);
.run(outcome, now, row.id);
moderationEvent(actor, `case.${outcome}`, row, row.id, reason, now);
return { id: row.id };
}
if (input.op === 'reopen-case') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
if (isModerationCaseOpen(row.status))
throw new AuthError('This case is already active.', 409);
const reason = textField(input.reason, 'Reopening reason', 4000);
evidence(actor, row.id, reason, 'Reopened', null, now, true);
db()
.prepare(
"UPDATE moderation_cases SET status='open',updated_at=?,revision=revision+1 WHERE id=?"
)
.run(now, row.id);
moderationEvent(
actor,
'case.reopened',
row,
row.id,
JSON.stringify({ from: row.status, reason }),
now
);
return { id: row.id };
}
if (input.op === 'attach-evidence') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const count = archivedEvidence(actor, row.id, row, input.evidence, now);
touchCase(row.id, now);
moderationEvent(
actor,
'case.evidence_attached',
row,
row.id,
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
now
);
return { id: row.id };
}
if (input.op === 'note') {
+86
View File
@@ -0,0 +1,86 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { moderationActor, textField } from './core.js';
import { moderationOverview } from './overview.js';
import type {
ModerationCase,
ModerationCaseDetail,
ModerationEvent,
ModerationSanction
} from '../../moderation.js';
/** A case owns its evidence and timeline independently of the paginated queues. */
export function moderationCaseDetail(actorId: string, id: string): ModerationCaseDetail {
moderationActor(actorId);
const caseId = textField(id, 'Case ID', 128);
return db().transaction(() => {
const handle = db();
const row = handle
.prepare(
`SELECT c.id,c.game,c.guid,c.title,c.status,c.priority,
c.assignee_id AS assigneeId,u.username AS assignee,c.actor,c.created_at AS createdAt,
c.updated_at AS updatedAt,c.revision FROM moderation_cases c
LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?`
)
.get(caseId) as Omit<ModerationCase, 'evidence'> | undefined;
if (!row) throw new AuthError('Case not found.', 404);
const overview = moderationOverview(actorId, {
view: 'players',
q: '',
game: row.game,
guid: row.guid,
offset: 0
});
const evidence = handle
.prepare(
`SELECT id,text,source,snapshot,actor,created_at AS createdAt
FROM moderation_evidence WHERE case_id=? ORDER BY created_at,rowid`
)
.all(caseId) as ModerationCase['evidence'];
const sanctions = (
handle
.prepare(
`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
FROM moderation_sanctions WHERE case_id=? ORDER BY created_at DESC,id`
)
.all(caseId) as Omit<ModerationSanction, 'active'>[]
).map((restriction) => ({
...restriction,
active:
!restriction.revokedAt &&
(!restriction.expiresAt || restriction.expiresAt > overview.checkedAt)
}));
const events = handle
.prepare(
`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
(SELECT id FROM moderation_sanctions WHERE case_id=?) ORDER BY id DESC`
)
.all(caseId, caseId) as ModerationEvent[];
const assignees = handle
.prepare(
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
AND disabled=0 AND must_change_password=0 ORDER BY username COLLATE NOCASE,id`
)
.all() as ModerationCaseDetail['assignees'];
return {
case: { ...row, evidence },
player: overview.players[0] ?? {
game: row.game,
guid: row.guid,
name: row.guid,
aliases: [],
servers: [],
restrictions: 0,
firstSeen: null,
lastSeen: null
},
sanctions,
events,
coverage: overview.coverage,
assignees,
checkedAt: overview.checkedAt
};
})();
}
+33 -4
View File
@@ -3,6 +3,7 @@ import { moderationActor, moderationNow, policyRevision } from './core.js';
import { runtimeFreshMs } from './actions.js';
import { refreshActionStates } from './runtime.js';
import {
moderationCaseStatuses,
moderationGuid,
type ModerationOverview,
type ModerationQuery,
@@ -19,12 +20,21 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
const game = params.get('game');
const guid = (params.get('guid') ?? '').slice(0, 128);
const offset = Number(params.get('offset') ?? 0);
const caseStatus = params.get('caseStatus') ?? '';
const priority = params.get('priority') ?? '';
return {
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
q: (params.get('q') ?? '').trim().slice(0, 200),
game: game === 't5' || game === 't6' ? game : '',
guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid,
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0,
caseStatus: ['active', 'closed', ...moderationCaseStatuses].includes(caseStatus)
? (caseStatus as ModerationQuery['caseStatus'])
: '',
priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
? (priority as ModerationQuery['priority'])
: '',
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128)
};
}
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
@@ -93,6 +103,25 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
where.push('p.guid=?');
values.push(query.guid);
}
if (query.view === 'cases') {
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
where.push(
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review','escalated')`
);
} else if (query.caseStatus) {
where.push('p.status=?');
values.push(query.caseStatus);
}
if (query.priority) {
where.push('p.priority=?');
values.push(query.priority);
}
if (query.assignedTo === 'unassigned') where.push('p.assignee_id IS NULL');
else if (query.assignedTo) {
where.push('p.assignee_id=?');
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
}
}
if (query.q) {
const extra =
query.view === 'restrictions'
@@ -161,14 +190,14 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
} else if (query.view === 'restrictions') {
result.sanctions = rows<Omit<ModerationSanction, 'active'>>(
'moderation_sanctions',
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision',
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision,p.case_id AS caseId',
'p.created_at DESC,p.id'
).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) }));
} else if (query.view === 'cases') {
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
'moderation_cases',
'p.id,p.game,p.guid,p.title,p.status,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
'p.updated_at DESC,p.id'
'p.id,p.game,p.guid,p.title,p.status,p.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
"CASE WHEN p.status IN ('open','in_review','escalated') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id"
).map((c) => ({
...c,
evidence: handle
+40
View File
@@ -109,3 +109,43 @@ export function migrateModeration(handle: Database.Database): void {
cursor = [last.game, last.guid];
}
}
/** The migration runner disables foreign keys before opening this transaction. */
export function migrateModerationCases(handle: Database.Database): void {
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
if (!columns.some((column) => column.name === 'priority')) {
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The moderation case migration requires foreign keys to be disabled');
// Do not rename the existing parent: archived evidence must keep its
// original case reference through the rebuild.
handle.exec(`
CREATE TABLE moderation_cases_expanded (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','in_review','escalated','resolved','dismissed',
'warning_issued','restriction_applied','no_action','duplicate','appeal_upheld','appeal_denied')),
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL
);
INSERT INTO moderation_cases_expanded(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
SELECT id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision FROM moderation_cases;
DROP TABLE moderation_cases;
ALTER TABLE moderation_cases_expanded RENAME TO moderation_cases;
`);
}
if (
!(handle.pragma('table_info(moderation_sanctions)') as { name: string }[]).some(
(column) => column.name === 'case_id'
)
)
handle.exec(
'ALTER TABLE moderation_sanctions ADD COLUMN case_id TEXT REFERENCES moderation_cases(id) ON DELETE SET NULL'
);
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_cases_queue ON moderation_cases(status,priority,updated_at);
CREATE INDEX IF NOT EXISTS moderation_cases_assignee ON moderation_cases(assignee_id,updated_at);
CREATE INDEX IF NOT EXISTS moderation_sanctions_case ON moderation_sanctions(case_id);
CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id);
`);
}
@@ -1,6 +1,14 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ModerationCoverage, ModerationGame, ModerationScope } from '$lib/moderation.js';
import {
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatusLabels,
type ModerationCoverage,
type ModerationGame,
type ModerationScope,
type ModerationCase
} from '$lib/moderation.js';
import { moderationGameName } from '$ui/moderation.js';
import { serverTitle } from '$ui/server-labels.js';
import { confirmAction } from '$ui/confirm.js';
@@ -17,12 +25,24 @@
coverage,
admin,
seed = {},
assignees = [],
onsubmitted
}: {
open?: boolean;
mode: 'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve';
mode:
| 'restriction'
| 'action'
| 'case'
| 'note'
| 'revoke'
| 'case-note'
| 'resolve'
| 'reopen'
| 'edit-case'
| 'evidence';
coverage: ModerationCoverage[];
admin: boolean;
assignees?: { id: string; username: string }[];
seed?: {
game?: ModerationGame;
guid?: string;
@@ -31,8 +51,15 @@
revision?: number;
matchId?: number;
message?: string;
caseId?: string;
caseRevision?: number;
title?: string;
priority?: ModerationCase['priority'];
assigneeId?: string | null;
assignee?: string | null;
status?: ModerationCase['status'];
};
onsubmitted: (target: { game?: ModerationGame; guid?: string | null }) => void;
onsubmitted: (target: { game?: ModerationGame; guid?: string | null; id?: string }) => void;
} = $props();
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
let guid = $state(untrack(() => seed.guid ?? ''));
@@ -43,7 +70,14 @@
let duration = $state('60');
let reason = $state('');
let note = $state('');
let title = $state('');
let title = $state(untrack(() => seed.title ?? ''));
let priority = $state(untrack(() => seed.priority ?? 'normal'));
let assigneeId = $state(untrack(() => seed.assigneeId ?? ''));
let status = $state(untrack(() => seed.status ?? 'open'));
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
let message = $state(untrack(() => seed.message ?? ''));
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
const stateChanged = $derived(mode === 'edit-case' && status !== seed.status);
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action');
let resolution = $state('resolved');
@@ -59,7 +93,10 @@
note: 'Add private note',
revoke: 'Revoke restriction',
'case-note': 'Add case note',
resolve: 'Review case'
resolve: 'Review case',
reopen: 'Reopen case',
'edit-case': 'Edit case',
evidence: 'Attach archived evidence'
}[mode]
);
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
@@ -101,7 +138,8 @@
server: scope === 'server' ? server : undefined,
reason: reason.trim(),
note: note.trim(),
durationMinutes: duration === 'permanent' ? null : Number(duration)
durationMinutes: duration === 'permanent' ? null : Number(duration),
...(seed.caseId ? { caseId: seed.caseId, caseRevision: seed.caseRevision } : {})
};
if (mode === 'action')
return {
@@ -124,7 +162,35 @@
: {})
};
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
if (mode === 'case-note') return { op: 'case-note', id: seed.id, text: note.trim() };
if (mode === 'case-note')
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
if (mode === 'reopen')
return {
op: 'reopen-case',
id: seed.id,
revision: seed.revision,
reason: reason.trim()
};
if (mode === 'edit-case')
return {
op: 'update-case',
id: seed.id,
revision: seed.revision,
title: title.trim(),
priority,
...(assigneeId !== (seed.assigneeId ?? '')
? { assigneeId: assigneeId || null }
: {}),
...(isModerationCaseOpen(status) ? { status } : {}),
reason: reason.trim()
};
if (mode === 'evidence')
return {
op: 'attach-evidence',
id: seed.id,
revision: seed.revision,
evidence: { matchId, message }
};
if (mode === 'revoke')
return {
op: 'revoke-sanction',
@@ -186,7 +252,8 @@
open = false;
onsubmitted({
game: pending.game as ModerationGame | undefined,
guid: pending.guid as string | null | undefined
guid: pending.guid as string | null | undefined,
id: typeof result.id === 'string' ? result.id : undefined
});
} catch (cause) {
if (pending) uncertain = true;
@@ -211,6 +278,7 @@
><Select
bind:value={game}
required
disabled={lockedTarget}
onchange={() => (server = '')}
options={[
{ value: '', label: 'Select a game' },
@@ -229,7 +297,13 @@
label="Player GUID"
required
hint="Use the recorded player identity. Names can change."
><Input name="target-guid" bind:value={guid} required mono /></Field
><Input
name="target-guid"
bind:value={guid}
required
mono
readonly={lockedTarget}
/></Field
>
{/if}
{/if}
@@ -293,11 +367,16 @@
required capability and current policy; review enforcement coverage after
saving.
</p>{/if}
{#if mode === 'case'}
{#if lockedTarget}<p class="hint">
This restriction will be linked to the case and its recorded player identity.
Applying it does not close the case; record the outcome when your review is
complete.
</p>{/if}
{#if mode === 'case' || mode === 'edit-case'}
<Field label="Case title" required
><Input bind:value={title} required maxlength={160} /></Field
>
{#if seed.matchId && seed.message}<div class="evidence">
{#if mode === 'case' && seed.matchId && seed.message}<div class="evidence">
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
<p>{seed.message}</p>
<small
@@ -306,22 +385,102 @@
>
</div>{/if}
{/if}
{#if mode === 'resolve'}<Field label="Decision"
{#if mode === 'edit-case'}
<div class="paired">
<Field label="Priority"
><Select
bind:value={priority}
options={[
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' }
]}
/></Field
>
<Field label="Assigned to"
><Select
bind:value={assigneeId}
options={[
{ value: '', label: 'Unassigned' },
...assignees.map((person) => ({
value: person.id,
label: person.username
})),
...(seed.assigneeId &&
!assignees.some((person) => person.id === seed.assigneeId)
? [
{
value: seed.assigneeId,
label: `${seed.assignee ?? 'Previous assignee'} (unavailable)`
}
]
: [])
]}
/></Field
>
</div>
{#if isModerationCaseOpen(seed.status ?? 'open')}
<Field label="Workflow status"
><Select
bind:value={status}
options={[
{ value: 'open', label: moderationCaseStatusLabels.open },
{ value: 'in_review', label: moderationCaseStatusLabels.in_review },
{ value: 'escalated', label: moderationCaseStatusLabels.escalated }
]}
/></Field
>
{:else}<p class="hint">
The recorded outcome is preserved. Reopen the case to resume review.
</p>{/if}
<Field
label="Change reason"
required={stateChanged}
hint="Required when changing workflow status."
>
<Textarea bind:value={reason} required={stateChanged} rows={3} />
</Field>
{/if}
{#if mode === 'evidence'}
<p class="hint">
Choose a match and copy the exact archived message from this player. The archive
must verify the case's game and player identity before evidence is attached.
</p>
<Field label="Match ID" required
><Input type="number" bind:value={matchId} min={1} step={1} required /></Field
>
<Field label="Exact archived message" required
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
>
{/if}
{#if mode === 'resolve'}<Field label="Outcome"
><Select
bind:value={resolution}
options={[
{ value: 'resolved', label: 'Resolved' },
{ value: 'dismissed', label: 'Dismissed' }
]}
options={moderationCaseOutcomes.map((value) => ({
value,
label: moderationCaseStatusLabels[value]
}))}
/></Field
>{/if}
{#if ['restriction', 'action', 'revoke', 'resolve'].includes(mode)}
{#if mode === 'resolve'}<p class="hint">
This records the decision and closes the case. Apply or revoke restrictions
separately, and send warnings through Live action. Choosing an outcome does not
change enforcement or send a warning.
</p>{/if}
{#if mode === 'reopen'}<p class="hint">
Reopening returns the case to Open and preserves its evidence, restrictions, and
earlier decisions.
</p>{/if}
{#if ['restriction', 'action', 'revoke', 'resolve', 'reopen'].includes(mode)}
<Field
label={mode === 'action' && ['message', 'announce'].includes(action)
? 'Message'
: mode === 'restriction' || mode === 'action'
? 'Player-visible reason'
: 'Review reason'}
: mode === 'reopen'
? 'Reopening reason'
: 'Review reason'}
hint={gameText
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
: undefined}
+64 -1
View File
@@ -1,6 +1,14 @@
import type { ModerationGame } from '$lib/moderation.js';
import {
moderationCaseStatusLabels,
type ModerationCaseStatus,
type ModerationGame
} from '$lib/moderation.js';
import { serverTitle } from './server-labels.js';
export function moderationCaseHref(id: string): string {
return `/moderation/cases/${encodeURIComponent(id)}`;
}
export function moderationHref(context: {
game?: string;
guid?: string;
@@ -35,6 +43,9 @@ export const moderationActionLabels: Record<string, string> = {
'create-case': 'Case opened',
'case-note': 'Case note added',
'resolve-case': 'Case reviewed',
'reopen-case': 'Case reopened',
'update-case': 'Case updated',
'attach-evidence': 'Case evidence attached',
note: 'Private note',
'restriction.created': 'Restriction created',
'restriction.revoked': 'Restriction revoked',
@@ -47,11 +58,63 @@ export const moderationActionLabels: Record<string, string> = {
'case.note': 'Case note added',
'case.resolved': 'Case resolved',
'case.dismissed': 'Case dismissed',
'case.in_review': 'Case under review',
'case.escalated': 'Case escalated',
'case.warning_issued': 'Warning issued',
'case.restriction_applied': 'Restriction applied',
'case.no_action': 'Closed without action',
'case.duplicate': 'Closed as duplicate',
'case.appeal_upheld': 'Appeal upheld',
'case.appeal_denied': 'Appeal denied',
'case.reopened': 'Case reopened',
'case.updated': 'Case updated',
'case.evidence_attached': 'Evidence attached',
'case.restriction': 'Restriction linked to case',
'player.note': 'Private player note',
'runtime.configure': 'Integration configuration requested'
};
export function moderationEventDetail(detail: string, action: string): string {
if (action.startsWith('case.')) {
try {
const record: unknown = JSON.parse(detail);
if (!record || typeof record !== 'object') return detail;
const value = record as Record<string, unknown>;
const display = (item: unknown, status = false): string =>
item === null
? 'Unassigned'
: typeof item === 'string'
? status && Object.hasOwn(moderationCaseStatusLabels, item)
? moderationCaseStatusLabels[item as ModerationCaseStatus]
: item
: '';
if (action === 'case.updated') {
const changes = ['title', 'priority', 'assignee', 'status'].flatMap((key) => {
const change = value[key];
if (!change || typeof change !== 'object') return [];
const { from, to } = change as { from?: unknown; to?: unknown };
return [
`${key === 'assignee' ? 'Assigned to' : key.charAt(0).toUpperCase() + key.slice(1)}: ${display(from, key === 'status')} → ${display(to, key === 'status')}`
];
});
if (typeof value.reason === 'string') changes.push(value.reason);
return changes.join('\n') || detail;
}
if (action === 'case.reopened' && typeof value.reason === 'string')
return `Previous outcome: ${display(value.from, true)}\n${value.reason}`;
if (
action === 'case.evidence_attached' &&
typeof value.count === 'number' &&
typeof value.matchId === 'number'
)
return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`;
if (action === 'case.restriction' && typeof value.reason === 'string')
return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`;
} catch {
// Older case events and decision reasons are plain staff-authored text.
}
return detail;
}
if (!['restriction.created', 'action.queued'].includes(action)) return detail;
try {
const value: unknown = JSON.parse(detail);
+8
View File
@@ -23,8 +23,16 @@ const states: Record<string, [string, StatusTone]> = {
'moderation-active': ['Active', 'warning'],
'moderation-revoked': ['Revoked', 'neutral'],
'moderation-open': ['Open', 'info'],
'moderation-in_review': ['In review', 'info'],
'moderation-escalated': ['Escalated', 'warning'],
'moderation-resolved': ['Resolved', 'online'],
'moderation-dismissed': ['Dismissed', 'neutral'],
'moderation-warning_issued': ['Warning issued', 'online'],
'moderation-restriction_applied': ['Restriction applied', 'online'],
'moderation-no_action': ['No action needed', 'neutral'],
'moderation-duplicate': ['Duplicate', 'neutral'],
'moderation-appeal_upheld': ['Appeal upheld', 'online'],
'moderation-appeal_denied': ['Appeal denied', 'neutral'],
'moderation-queued': ['Queued', 'neutral'],
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
'moderation-confirmed': ['Confirmed', 'online'],
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { authError, requireUser } from '$lib/server/auth/http.js';
import { moderationCaseDetail } from '$lib/server/moderation/cases.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ locals, params }) => {
try {
return json(moderationCaseDetail(requireUser(locals.user).id, params.id), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return authError(cause);
}
};
+109 -53
View File
@@ -5,6 +5,8 @@
import { canModerate } from '$lib/auth.js';
import {
canRevokeModerationRestriction,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationOverview,
type ModerationQuery,
type ModerationGame
@@ -14,7 +16,8 @@
import {
moderationActionLabels,
moderationGameName,
moderationEventDetail
moderationEventDetail,
moderationCaseHref
} from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js';
@@ -52,9 +55,7 @@
let generation = 0;
let dialogOpen = $state(false);
let dialogKey = $state(0);
let mode = $state<
'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve'
>('restriction');
let mode = $state<'restriction' | 'action' | 'case' | 'note' | 'revoke'>('restriction');
let seed = $state<{
game?: ModerationGame;
guid?: string;
@@ -160,7 +161,15 @@
dialogKey++;
dialogOpen = true;
}
function submitted(target: { game?: ModerationGame; guid?: string | null }) {
function submitted(target: { game?: ModerationGame; guid?: string | null; id?: string }) {
if (mode === 'case' && target.id) {
cancelRefresh();
void goto(moderationCaseHref(target.id), { invalidateAll: true }).catch(() => {
navigationError = 'Case saved. Open it from the case list or refresh to try again.';
void refresh();
});
return;
}
notice = 'Saved. Enforcement and delivery status are shown below.';
cancelRefresh();
const view =
@@ -320,7 +329,15 @@
class="filters"
onsubmit={(event) => {
event.preventDefault();
void navigate({ q: filters.q, game: filters.game, guid: filters.guid, offset: null });
void navigate({
q: filters.q,
game: filters.game,
guid: filters.guid,
caseStatus: filters.caseStatus ?? null,
priority: filters.priority ?? null,
assignedTo: filters.assignedTo ?? null,
offset: null
});
}}
>
<Field label="Search moderation" class="search"
@@ -343,6 +360,47 @@
/></Field
>
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
{#if report.query.view === 'cases'}
<Field label="Case status"
><Select
name="caseStatus"
bind:value={filters.caseStatus}
options={[
{ value: '', label: 'All statuses' },
{ value: 'active', label: 'Active cases' },
{ value: 'closed', label: 'Closed cases' },
...moderationCaseStatuses.map((status) => ({
value: status,
label: moderationCaseStatusLabels[status]
}))
]}
/></Field
>
<Field label="Priority"
><Select
name="priority"
bind:value={filters.priority}
options={[
{ value: '', label: 'All priorities' },
{ value: 'urgent', label: 'Urgent' },
{ value: 'high', label: 'High' },
{ value: 'normal', label: 'Normal' },
{ value: 'low', label: 'Low' }
]}
/></Field
>
<Field label="Assignment"
><Select
name="assignedTo"
bind:value={filters.assignedTo}
options={[
{ value: '', label: 'Anyone' },
{ value: 'me', label: 'Assigned to me' },
{ value: 'unassigned', label: 'Unassigned' }
]}
/></Field
>
{/if}
<Button type="submit" disabled={busy}>Apply</Button>
<Button
disabled={busy}
@@ -354,7 +412,10 @@
offset: null,
server: null,
message: null,
matchId: null
matchId: null,
caseStatus: null,
priority: null,
assignedTo: null
})}>Reset</Button
>
</form>
@@ -507,6 +568,9 @@
</dd>
</div>
</dl>
{#if sanction.caseId}<a href={moderationCaseHref(sanction.caseId)}
>View linked case</a
>{/if}
{#if sanction.note}<p class="private-note">
<strong>Private note</strong>
{sanction.note}
@@ -536,47 +600,46 @@
{#if report.cases.length}<div class="record-list">
{#each report.cases as item (item.id)}<article class="record">
<div class="record-heading">
<h2>{item.title}</h2>
<h2><a href={moderationCaseHref(item.id)}>{item.title}</a></h2>
<StatusBadge state={`moderation-${item.status}`} />
</div>
<p>
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
>{item.guid}</a
>
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime
value={item.createdAt}
/>
· {moderationGameName(item.game)}
</p>
<details>
<summary>Evidence and staff notes ({item.evidence.length})</summary>
<ol class="evidence-list">
{#each item.evidence as evidence (evidence.id)}<li>
<p>{evidence.text}</p>
<small
>{evidence.source} · {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/></small
>
</li>{:else}<li>No evidence or notes recorded.</li>{/each}
</ol>
</details>
<dl>
<div>
<dt>Priority</dt>
<dd>
{item.priority.charAt(0).toUpperCase() +
item.priority.slice(1)}
</dd>
</div>
<div>
<dt>Assigned to</dt>
<dd>{item.assignee ?? 'Unassigned'}</dd>
</div>
<div>
<dt>Updated</dt>
<dd><RelativeTime value={item.updatedAt} /></dd>
</div>
<div>
<dt>Opened by</dt>
<dd>{item.actor} · <RelativeTime value={item.createdAt} /></dd>
</div>
</dl>
<div class="record-heading">
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div
class="row-actions"
>
<Button
size="sm"
onclick={() => openDialog('case-note', { id: item.id })}
>Add case note</Button
>{#if item.status === 'open'}<Button
size="sm"
onclick={() =>
openDialog('resolve', {
id: item.id,
revision: item.revision
})}>Review case</Button
>{/if}
</div>{/if}
<span class="hint"
>{item.evidence.length} evidence and staff note {item.evidence
.length === 1
? 'entry'
: 'entries'}</span
>
<Button href={moderationCaseHref(item.id)} size="sm"
>View case</Button
>
</div>
</article>{/each}
</div>{:else}<EmptyState
@@ -628,10 +691,12 @@
: ''}{event.game
? ` · ${moderationGameName(event.game)}`
: ''}</small
>{#if event.subjectId}<CopyValue
value={event.subjectId}
label="Record ID"
/>{/if}
>{#if event.subjectId}
{#if event.action.startsWith('case.')}<a
href={moderationCaseHref(event.subjectId)}>View case</a
>{/if}
<CopyValue value={event.subjectId} label="Record ID" />
{/if}
</li>{/each}
</ol>{:else}<EmptyState
title="No history found"
@@ -788,15 +853,6 @@
.private-note {
font-size: var(--text-sm);
}
.evidence-list {
padding-left: var(--space-5);
}
.evidence-list li {
margin-top: var(--space-4);
}
.evidence-list small {
font-size: var(--text-xs);
}
.audit-list {
list-style: none;
padding: 0;
@@ -0,0 +1,15 @@
import { error } from '@sveltejs/kit';
import { AuthError } from '$lib/server/auth/index.js';
import { requireUser } from '$lib/server/auth/http.js';
import { moderationCaseDetail } from '$lib/server/moderation/cases.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals, params, setHeaders }) => {
setHeaders({ 'Cache-Control': 'no-store' });
try {
return { moderationCase: moderationCaseDetail(requireUser(locals.user).id, params.id) };
} catch (cause) {
if (cause instanceof AuthError) error(cause.status, cause.message);
throw cause;
}
};
@@ -0,0 +1,651 @@
<script lang="ts">
import { onMount, untrack, type ComponentProps } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { navigating, page } from '$app/stores';
import { canModerate } from '$lib/auth.js';
import {
canRevokeModerationRestriction,
isModerationCaseOpen,
type ModerationCaseDetail
} from '$lib/moderation.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import { contextHref } from '$lib/navigation-context.js';
import {
moderationActionLabels,
moderationEventDetail,
moderationGameName,
moderationHref
} from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
let snapshot = $state<ModerationCaseDetail | null>(null);
const report = $derived(snapshot ?? data.moderationCase);
const item = $derived(report.case);
const active = $derived(isModerationCaseOpen(item.status));
const permitted = $derived(canModerate($page.data?.user?.role));
const admin = $derived($page.data?.user?.role === 'admin');
const busy = $derived(!!$navigating);
const archivedEvidence = $derived(item.evidence.filter((entry) => !!entry.snapshot));
const notes = $derived(item.evidence.filter((entry) => !entry.snapshot));
const coverage = $derived(
report.coverage.filter(
(server) =>
server.game === item.game ||
report.sanctions.some((restriction) => restriction.scope === 'fleet')
)
);
let refreshing = $state(false);
let refreshError = $state('');
let notice = $state('');
let updatedAt = $state<number | null>(null);
let generation = 0;
let controller: AbortController | undefined;
let dialogOpen = $state(false);
let dialogKey = $state(0);
let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note');
let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({});
$effect(() => {
const initial = data.moderationCase;
untrack(() => {
cancelRefresh();
snapshot = null;
refreshError = '';
notice = '';
dialogOpen = false;
updatedAt = Date.parse(initial.checkedAt);
});
});
function cancelRefresh() {
generation++;
controller?.abort();
refreshing = false;
}
beforeNavigate(cancelRefresh);
async function refresh(signal?: AbortSignal) {
if (busy || refreshing) return;
const current = ++generation;
const request = new AbortController();
controller = request;
const abort = () => request.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) request.abort();
const timeout = setTimeout(abort, 30_000);
refreshing = true;
try {
const response = await fetch(`/api/moderation/cases/${encodeURIComponent(item.id)}`, {
signal: request.signal
});
if (!response.ok) throw new Error('Refresh failed');
const next: ModerationCaseDetail = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation)
refreshError =
'Case updates paused. Showing the last loaded records. Refresh before making another change.';
return false;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
if (current === generation) refreshing = false;
}
}
onMount(() => {
updatedAt = Date.now();
const stop = pollVisible(refresh, 15_000, false);
return () => {
stop();
cancelRefresh();
};
});
function openDialog(next: typeof mode, target: typeof seed = {}) {
mode = next;
const matchId = Number($page.url.searchParams.get('matchId'));
const message = $page.url.searchParams.get('message');
seed = {
game: item.game,
guid: item.guid,
id: item.id,
revision: item.revision,
title: item.title,
priority: item.priority,
assigneeId: item.assigneeId,
assignee: item.assignee,
status: item.status,
...(next === 'restriction' ? { caseId: item.id, caseRevision: item.revision } : {}),
...(next === 'evidence' && Number.isSafeInteger(matchId) && matchId > 0 && message
? { matchId, message }
: {}),
...target
};
dialogKey++;
dialogOpen = true;
}
function submitted() {
notice =
mode === 'restriction'
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you record an outcome.'
: mode === 'revoke'
? 'Revocation saved. Servers apply it when they receive the latest policy.'
: 'Case updated. The change is recorded in the timeline.';
cancelRefresh();
void refresh();
}
function archiveContext(raw: string | null | undefined) {
if (!raw) return null;
try {
const value = JSON.parse(raw);
if (!value || typeof value !== 'object') return null;
return {
matchId:
Number.isSafeInteger(value.matchId) && value.matchId > 0
? (value.matchId as number)
: null,
server:
typeof value.match?.server === 'string' ? (value.match.server as string) : null,
startedAt:
typeof value.match?.started_at === 'string'
? (value.match.started_at as string)
: null,
player:
typeof value.entry?.message?.player === 'string'
? (value.entry.message.player as string)
: null,
time:
typeof value.entry?.message?.time === 'string'
? (value.entry.message.time as string)
: null,
realTime:
typeof value.entry?.context?.realTime === 'string'
? (value.entry.context.realTime as string)
: null,
round: Number.isInteger(value.entry?.context?.round)
? (value.entry.context.round as number)
: null,
original: JSON.stringify(value, null, 2)
};
} catch {
return null;
}
}
</script>
<svelte:head><title>{item.title} · Moderation · Electric Cherry</title></svelte:head>
<div class="case-page">
<SectionHeader
title={item.title}
eyebrow="Moderation case"
subtitle={`${moderationGameName(item.game)} · ${report.player.name}`}
>
{#snippet crumbs()}<a href="/moderation?view=cases">Moderation cases</a>{/snippet}
{#snippet actions()}
<Freshness
{updatedAt}
intervalLabel="every 15 s"
error={refreshError ? 'Updates paused' : ''}
/>
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
{#if permitted}
{#if active}<Button variant="primary" onclick={() => openDialog('resolve')}
>Review case</Button
>
{:else}<Button variant="primary" onclick={() => openDialog('reopen')}
>Reopen case</Button
>{/if}
{/if}
{/snippet}
</SectionHeader>
{#if !permitted}<p class="notice">
Read-only moderation access. You can inspect this case, its evidence, and linked
restrictions.
</p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if refreshError}<div class="ui-error" role="alert">
{refreshError}<Button size="sm" onclick={() => refresh()} loading={refreshing}
>Retry</Button
>
</div>{/if}
<section class="case-summary panel" aria-label="Case overview">
<div class="row">
<StatusBadge state={`moderation-${item.status}`} />
<Badge
variant={item.priority === 'urgent'
? 'danger'
: item.priority === 'high'
? 'warning'
: 'neutral'}
>{item.priority[0].toUpperCase() + item.priority.slice(1)} priority</Badge
>
<span>{active ? 'Active case' : 'Closed case'}</span>
{#if permitted}<Button size="sm" onclick={() => openDialog('edit-case')}
>Edit case</Button
>{/if}
</div>
<dl class="case-metadata">
<div>
<dt>Assigned to</dt>
<dd>{item.assignee ?? 'Unassigned'}</dd>
</div>
<div>
<dt>Opened by</dt>
<dd>{item.actor} · <RelativeTime value={item.createdAt} /></dd>
</div>
<div>
<dt>Last updated</dt>
<dd><RelativeTime value={item.updatedAt} /></dd>
</div>
</dl>
<CopyValue value={item.id} label="Case ID" />
</section>
<div class="workspace">
<div class="case-main">
<section class="panel" aria-labelledby="evidence-title">
<div class="section-heading">
<h2 id="evidence-title">
Archived evidence <span>({archivedEvidence.length})</span>
</h2>
{#if permitted}<Button size="sm" onclick={() => openDialog('evidence')}
>Attach evidence</Button
>{/if}
</div>
{#if archivedEvidence.length}
<ol class="records">
{#each archivedEvidence as evidence (evidence.id)}
{@const archive = archiveContext(evidence.snapshot)}
<li class="record">
<div class="row">
<strong>{archive?.player ?? 'Archived message'}</strong
>{#if archive?.round !== null && archive?.round !== undefined}<Badge
>Round {archive.round}</Badge
>{/if}
</div>
<blockquote>{evidence.text}</blockquote>
<p class="hint">{evidence.source}</p>
{#if archive}<div class="source-context">
{#if archive.matchId}<a
href={contextHref(
`/matches/${archive.matchId}`,
$page.url
)}>View match #{archive.matchId}</a
>{/if}
{#if archive.realTime}<span
>Estimated message time · {$timeFormat.dateTime(
archive.realTime
)}</span
>{/if}
{#if archive.time}<span
>Original log time · {archive.time}</span
>{/if}
{#if archive.startedAt}<span
>Match started · {$timeFormat.dateTime(
archive.startedAt
)}</span
>{/if}
</div>{/if}
<p class="hint">
Preserved by {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/>
</p>
{#if archive}<details>
<summary>Preserved archive snapshot</summary>
<pre>{archive.original}</pre>
</details>{/if}
</li>
{/each}
</ol>
{:else}<EmptyState
title="No archived evidence yet"
description="Attach a verified chat message to preserve its original player, match, and timing context."
/>{/if}
</section>
<section class="panel" aria-labelledby="notes-title">
<div class="section-heading">
<h2 id="notes-title">
Staff notes and decisions <span>({notes.length})</span>
</h2>
{#if permitted}<Button size="sm" onclick={() => openDialog('case-note')}
>Add case note</Button
>{/if}
</div>
{#if notes.length}<ol class="records">
{#each notes as evidence (evidence.id)}<li class="record">
<div class="row">
<strong>{evidence.source}</strong><RelativeTime
value={evidence.createdAt}
/>
</div>
<p class="record-text">{evidence.text}</p>
<p class="hint">{evidence.actor}</p>
</li>{/each}
</ol>{:else}<p class="hint">No staff notes or decisions recorded.</p>{/if}
</section>
<section class="panel" aria-labelledby="timeline-title">
<div class="section-heading">
<h2 id="timeline-title">Case timeline</h2>
<span class="hint">Newest first</span>
</div>
{#if report.events.length}<ol class="records timeline">
{#each report.events as event (event.id)}<li class="record">
<div class="section-heading">
<strong
>{moderationActionLabels[event.action] ??
event.action}</strong
><RelativeTime value={event.createdAt} />
</div>
<p class="record-text">
{moderationEventDetail(event.detail, event.action)}
</p>
<p class="hint">{event.actor}</p>
</li>{/each}
</ol>{:else}<p class="hint">No case activity recorded yet.</p>{/if}
</section>
</div>
<aside class="case-sidebar">
<section class="panel" aria-labelledby="player-title">
<h2 id="player-title">Player identity</h2>
<a class="player-name" href={contextHref(playerProfileHref(item.guid), $page.url)}
>{report.player.name}</a
>
<p class="hint">{moderationGameName(item.game)}</p>
<CopyValue value={item.guid} label="Player GUID" />
<dl>
<div>
<dt>Known aliases</dt>
<dd>{report.player.aliases.join(', ') || 'None recorded'}</dd>
</div>
<div>
<dt>Last seen</dt>
<dd>
<RelativeTime value={report.player.lastSeen} missing="Not recorded" />
</dd>
</div>
<div>
<dt>Active restrictions</dt>
<dd>{report.player.restrictions}</dd>
</div>
</dl>
<a
href={`${moderationHref({ game: item.game, guid: item.guid })}&view=restrictions`}
>View all player restrictions</a
>
</section>
<section class="panel" aria-labelledby="restrictions-title">
<div class="section-heading">
<h2 id="restrictions-title">Linked restrictions</h2>
{#if permitted && active}<Button
size="sm"
variant="danger"
onclick={() => openDialog('restriction')}>Restrict player</Button
>{/if}
</div>
{#if !active && permitted}<p class="hint">
Reopen this case before applying another restriction.
</p>{/if}
{#if report.sanctions.length}<ol class="records">
{#each report.sanctions as restriction (restriction.id)}<li class="record">
<div class="row">
<strong>{restriction.kind === 'ban' ? 'Ban' : 'Mute'}</strong
><StatusBadge
state={`moderation-${restriction.active ? 'active' : restriction.revokedAt ? 'revoked' : 'expired'}`}
/>
</div>
<p class="record-text">{restriction.reason}</p>
<dl>
<div>
<dt>Scope</dt>
<dd>
{#if restriction.server}<ServerIdentity
name={restriction.server}
/>{:else}{restriction.scope === 'fleet'
? 'Entire fleet'
: moderationGameName(restriction.game)}{/if}
</dd>
</div>
<div>
<dt>Expires</dt>
<dd>
{restriction.expiresAt
? $timeFormat.dateTime(restriction.expiresAt)
: 'Permanent'}
</dd>
</div>
<div>
<dt>Applied by</dt>
<dd>
{restriction.actor} · <RelativeTime
value={restriction.createdAt}
/>
</dd>
</div>
{#if restriction.revokedAt}<div>
<dt>Revoked</dt>
<dd>{$timeFormat.dateTime(restriction.revokedAt)}</dd>
</div>{/if}
</dl>
{#if restriction.note}<p class="record-text">
<strong>Private note</strong> · {restriction.note}
</p>{/if}
<CopyValue value={restriction.id} label="Restriction ID" />
{#if restriction.active && canRevokeModerationRestriction($page.data?.user, restriction)}<Button
size="sm"
onclick={() =>
openDialog('revoke', {
id: restriction.id,
revision: restriction.revision
})}>Revoke restriction</Button
>{/if}
</li>{/each}
</ol>{:else}<p class="hint">
No restrictions have been applied from this case.
</p>{/if}
<p class="hint">
Applying or revoking a restriction does not change the case outcome.
</p>
</section>
<section class="panel" aria-labelledby="coverage-title">
<h2 id="coverage-title">Enforcement coverage</h2>
<p class="hint">
Current means a server acknowledged the latest policy. Pending or unavailable
servers may not apply a new restriction or revocation yet.
</p>
{#if coverage.length}<ul class="records">
{#each coverage as server (server.server)}<li class="record">
<a href={`/servers/${encodeURIComponent(server.server)}`}
><ServerIdentity name={server.server} /></a
><StatusBadge state={`moderation-${server.state}`} />
<p class="hint">
Policy {server.revision ?? '—'} / {server.desiredRevision} · {server.capabilities.join(
', '
) || 'No capabilities reported'}
</p>
</li>{/each}
</ul>{:else}<p class="hint">
No server enforcement reports are available yet.
</p>{/if}
</section>
</aside>
</div>
</div>
{#if permitted}{#key dialogKey}<ModerationDialog
bind:open={dialogOpen}
{mode}
{seed}
{admin}
coverage={report.coverage}
assignees={report.assignees}
onsubmitted={submitted}
/>{/key}{/if}
<style>
.case-page,
.case-main,
.case-sidebar,
.panel,
.records,
.record {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.case-page,
.case-main,
.case-sidebar {
gap: var(--space-5);
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr);
gap: var(--space-5);
align-items: start;
}
.case-main,
.case-sidebar {
align-content: start;
}
.panel {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
}
.row,
.section-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.section-heading {
justify-content: space-between;
}
.row > span {
color: var(--text-secondary);
font-size: var(--text-sm);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
h2 span {
font-weight: var(--weight-regular);
color: var(--text-tertiary);
}
.records {
list-style: none;
padding: 0;
margin: 0;
}
.record {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-4);
gap: var(--space-3);
}
.record-text,
blockquote,
p {
margin: 0;
overflow-wrap: anywhere;
}
.record-text,
blockquote {
white-space: pre-wrap;
}
blockquote {
padding: var(--space-3);
border-left: 3px solid var(--accent-primary);
background: var(--surface-sunken);
border-radius: var(--radius-sm);
}
.hint,
dt,
.source-context,
summary {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.source-context {
display: grid;
gap: var(--space-2);
}
.notice {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-4);
background: var(--surface-raised);
font-size: var(--text-sm);
}
dl {
display: grid;
gap: var(--space-3);
margin: 0;
}
.case-metadata {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
dl > div {
display: grid;
gap: var(--space-1);
min-width: 0;
}
dd {
margin: 0;
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.player-name {
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
}
pre {
padding: var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-sunken);
font-size: var(--text-xs);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 960px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.case-metadata {
grid-template-columns: 1fr;
}
.panel {
padding: var(--space-4);
}
}
</style>