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
+37 -11
View File
@@ -125,18 +125,44 @@ resetting its cache or credentials.
## Cases, evidence and review ## Cases, evidence and review
Create a case for a report, investigation or appeal. Staff notes are explicitly Create a case for a report, investigation or appeal. Each case has a dedicated,
labeled as notes. Selecting archived chat verifies the target GUID and original shareable page with the player identity, evidence, staff notes, linked restrictions,
message against the recorded match and saves an independent snapshot, including and an activity timeline. These pages require moderation access. Give a case a
the original timing context. Unknown or estimated timestamps remain such; the priority and assign it to an eligible administrator or moderator. Filter the case
snapshot does not turn them into exact observations. Unarchived or already queue by status, priority, or assignment; active cases appear first, ordered by
deleted chat cannot be attached as verified evidence. priority and most recent update.
Cases can be resolved or dismissed with a recorded reason. Evidence, restrictions Move active cases between **Open**, **In review**, and **Escalated**, recording a
and append-only moderation events survive source-match cleanup and server reason for workflow changes. Close a case with one of these outcomes: **Resolved**,
deletion. They currently have no automatic retention cleanup. Authentication **Dismissed**, **Warning issued**, **Restriction applied**, **No action needed**,
audit cleanup does not remove moderation history. Correct decisions by adding **Duplicate**, **Appeal upheld**, or **Appeal denied**. Every decision requires a
notes or revoking restrictions; there is no history-erasing delete endpoint. reason. Reopen a closed case when new evidence or an appeal needs review; earlier
decisions remain in its history. Editing case details does not reopen a case.
Apply a ban or mute directly from an active case. The case fixes the target game
and GUID and records the resulting restriction alongside the evidence. The same
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
restriction leaves the case active so staff can finish reviewing it. Selecting a
closure outcome records a decision; it does not itself send a warning, create a
restriction, or revoke one. Linked restrictions can be revoked separately with
the required permission and reason, including after a case closes. Reopening a
case also leaves its restrictions in place. Check enforcement coverage after
applying or revoking a restriction.
Staff notes are explicitly labeled as notes. Selecting archived chat when opening
a case, or attaching a match ID and exact message within an existing case, verifies
the target GUID and original message against the recorded match and saves an
independent snapshot, including the original timing context. Unknown or estimated
timestamps remain such; the snapshot does not turn them into exact observations.
Unarchived or already deleted chat cannot be attached as verified evidence.
Case decisions, edits, and restrictions check the current revision; refresh before
retrying a decision if another staff member has changed the case. Evidence,
restrictions and append-only moderation events survive source-match cleanup and
server deletion. They currently have no automatic retention cleanup.
Authentication audit cleanup does not remove moderation history. Correct decisions
by reopening, adding notes, or revoking restrictions; there is no history-erasing
delete endpoint.
Leaderboard disqualification, bank balance changes, automatic escalation and a Leaderboard disqualification, bank balance changes, automatic escalation and a
public report/appeal portal are separate features. This workflow supports staff public report/appeal portal are separate features. This workflow supports staff
+60 -2
View File
@@ -6,6 +6,41 @@ export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
export type ModerationState = export type ModerationState =
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown'; '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 { export interface ModerationPlayer {
game: ModerationGame; game: ModerationGame;
guid: string; guid: string;
@@ -18,6 +53,7 @@ export interface ModerationPlayer {
} }
export interface ModerationSanction { export interface ModerationSanction {
id: string; id: string;
caseId?: string | null;
game: ModerationGame; game: ModerationGame;
guid: string; guid: string;
kind: 'ban' | 'mute'; kind: 'ban' | 'mute';
@@ -68,12 +104,31 @@ export interface ModerationCase {
game: ModerationGame; game: ModerationGame;
guid: string; guid: string;
title: string; title: string;
status: 'open' | 'resolved' | 'dismissed'; status: ModerationCaseStatus;
priority: ModerationCasePriority;
assigneeId: string | null;
assignee: string | null;
actor: string; actor: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
revision: number; 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 { export interface ModerationEvent {
id: number; id: number;
@@ -100,6 +155,9 @@ export interface ModerationQuery {
game: '' | ModerationGame; game: '' | ModerationGame;
guid: string; guid: string;
offset: number; offset: number;
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
priority?: '' | ModerationCasePriority;
assignedTo?: string;
} }
export interface ModerationOverview { export interface ModerationOverview {
query: ModerationQuery; query: ModerationQuery;
+8 -2
View File
@@ -27,7 +27,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js'; import { migrateBank } from './bank.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 } from '../moderation/schema.js'; import { migrateModeration, migrateModerationCases } 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
@@ -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; export const databaseVersion = migrations.length;
+214 -35
View File
@@ -4,7 +4,12 @@ import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import { import {
canRevokeModerationRestriction, canRevokeModerationRestriction,
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatusLabels,
moderationGuid, moderationGuid,
type ModerationCaseStatus,
type ModerationCasePriority,
type ModerationGame, type ModerationGame,
type ModerationScope type ModerationScope
} from '../../moderation.js'; } from '../../moderation.js';
@@ -34,8 +39,18 @@ type SanctionRow = {
expires_at: string | null; expires_at: string | null;
revision: number; revision: number;
revoked_at: string | null; 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) { function serverTarget(name: unknown, game?: ModerationGame) {
const server = textField(name, 'Server', 255); 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) { function createSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
const target = subject(input); 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 (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.');
if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope)) if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope))
throw new AuthError('Choose a restriction 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); ensureSubject(target.game, target.guid);
db() db()
.prepare( .prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at) `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(?,?,?,?,?,?,?,?,?,?,?,?,?)` VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
) )
.run( .run(
id, id,
@@ -133,7 +157,8 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
actor.id, actor.id,
actor.username, actor.username,
now, now,
expires expires,
linkedCase?.id ?? null
); );
advancePolicy(); advancePolicy();
moderationEvent( moderationEvent(
@@ -147,10 +172,22 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
server: server?.name ?? null, server: server?.name ?? null,
reason, reason,
expiresAt: expires, expiresAt: expires,
note note,
caseId: linkedCase?.id ?? null
}), }),
now 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 }; return { id };
} }
function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: string) { 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); .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);
return { id }; return { id };
} }
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) { 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); if (!row) throw new AuthError('Case not found.', 404);
return row; 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( function evidence(
actor: AuthUser, actor: AuthUser,
id: string, id: string,
text: string, text: string,
source: string, source: string,
snapshot: string | null, snapshot: string | null,
now: string now: string,
decision = false
) { ) {
if ( if (
!decision &&
( (
db() db()
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?') .prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
@@ -291,23 +357,62 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
const target = subject(input); const target = subject(input);
const title = textField(input.title, 'Case title', 160); const title = textField(input.title, 'Case title', 160);
const note = textField(input.note, 'Case note', 4000, true); 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(); const id = randomUUID();
ensureSubject(target.game, target.guid); ensureSubject(target.game, target.guid);
db() db()
.prepare( .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 (note) evidence(actor, id, note, 'Staff note', null, now);
if (input.evidence !== undefined) { if (input.evidence !== undefined) archivedEvidence(actor, id, target, input.evidence, now);
const requested = input.evidence as { matchId: unknown; message: unknown }; 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)) if (!requested || !Number.isSafeInteger(requested.matchId))
throw new AuthError('Choose a recorded chat message.'); throw new AuthError('Choose a recorded chat message.');
const message = textField(requested.message, 'Chat message', 4096); textField(requested.message, 'Chat message', 4096);
// Evidence must match the archived bytes, including intentional whitespace.
const message = requested.message as string;
const match = db() const match = db()
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?') .prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
.get(requested.matchId) as .get(requested.matchId) as { game: string; server: string; started_at: string } | undefined;
{ game: string; server: string; started_at: string } | undefined;
if (!match || match.game !== target.game) if (!match || match.game !== target.game)
throw new AuthError('The source match is unavailable.', 404); throw new AuthError('The source match is unavailable.', 404);
const entries = db() const entries = db()
@@ -324,10 +429,7 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
}) })
.slice(0, 10); .slice(0, 10);
if (!found.length) if (!found.length)
throw new AuthError( throw new AuthError('The original chat message could not be verified in the archive.', 409);
'The original chat message could not be verified in the archive.',
409
);
for (const row of found) for (const row of found)
evidence( evidence(
actor, actor,
@@ -337,9 +439,56 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }), JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
now 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 };
} }
moderationEvent(actor, 'case.opened', target, id, title, now); if (!Object.keys(changes).length) throw new AuthError('Change the case details before saving.');
return { id }; 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>) { export function mutateModeration(actorId: string, input: Record<string, unknown>) {
return moderationMutation(actorId, input, (actor, now) => { return moderationMutation(actorId, input, (actor, now) => {
@@ -347,37 +496,67 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
if (input.op === 'revoke-sanction') return revokeSanction(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 === '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 === '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);
const text = textField(input.text, 'Note', 4000); const text = textField(input.text, 'Note', 4000);
evidence(actor, row.id, text, 'Staff note', null, now); evidence(actor, row.id, text, 'Staff note', null, now);
db() touchCase(row.id, now);
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
.run(now, row.id);
moderationEvent(actor, 'case.note', row, row.id, text, now); moderationEvent(actor, 'case.note', row, row.id, text, now);
return { id: row.id }; return { id: row.id };
} }
if (input.op === 'resolve-case') { if (input.op === 'resolve-case') {
const row = caseRow(input.id); 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); throw new AuthError('This case changed. Refresh before resolving it.', 409);
if (input.status !== 'resolved' && input.status !== 'dismissed') const outcome = moderationCaseOutcomes.find((status) => status === input.status);
throw new AuthError('Choose resolved or dismissed.'); 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);
evidence( evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
actor,
row.id,
reason,
input.status === 'resolved' ? 'Resolution' : 'Dismissal',
null,
now
);
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(input.status, now, row.id); .run(outcome, now, row.id);
moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now); 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 }; return { id: row.id };
} }
if (input.op === 'note') { 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 { runtimeFreshMs } from './actions.js';
import { refreshActionStates } from './runtime.js'; import { refreshActionStates } from './runtime.js';
import { import {
moderationCaseStatuses,
moderationGuid, moderationGuid,
type ModerationOverview, type ModerationOverview,
type ModerationQuery, type ModerationQuery,
@@ -19,12 +20,21 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
const game = params.get('game'); const game = params.get('game');
const guid = (params.get('guid') ?? '').slice(0, 128); const guid = (params.get('guid') ?? '').slice(0, 128);
const offset = Number(params.get('offset') ?? 0); const offset = Number(params.get('offset') ?? 0);
const caseStatus = params.get('caseStatus') ?? '';
const priority = params.get('priority') ?? '';
return { return {
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players', view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
q: (params.get('q') ?? '').trim().slice(0, 200), q: (params.get('q') ?? '').trim().slice(0, 200),
game: game === 't5' || game === 't6' ? game : '', game: game === 't5' || game === 't6' ? game : '',
guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid, 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 { export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
@@ -93,6 +103,25 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
where.push('p.guid=?'); where.push('p.guid=?');
values.push(query.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) { if (query.q) {
const extra = const extra =
query.view === 'restrictions' query.view === 'restrictions'
@@ -161,14 +190,14 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
} else if (query.view === 'restrictions') { } else if (query.view === 'restrictions') {
result.sanctions = rows<Omit<ModerationSanction, 'active'>>( result.sanctions = rows<Omit<ModerationSanction, 'active'>>(
'moderation_sanctions', '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' 'p.created_at DESC,p.id'
).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) })); ).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) }));
} else if (query.view === 'cases') { } else if (query.view === 'cases') {
result.cases = rows<Omit<ModerationCase, 'evidence'>>( result.cases = rows<Omit<ModerationCase, 'evidence'>>(
'moderation_cases', '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.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',
'p.updated_at DESC,p.id' "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) => ({ ).map((c) => ({
...c, ...c,
evidence: handle evidence: handle
+40
View File
@@ -109,3 +109,43 @@ export function migrateModeration(handle: Database.Database): void {
cursor = [last.game, last.guid]; 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"> <script lang="ts">
import { untrack } from 'svelte'; 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 { moderationGameName } from '$ui/moderation.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';
@@ -17,12 +25,24 @@
coverage, coverage,
admin, admin,
seed = {}, seed = {},
assignees = [],
onsubmitted onsubmitted
}: { }: {
open?: boolean; 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[]; coverage: ModerationCoverage[];
admin: boolean; admin: boolean;
assignees?: { id: string; username: string }[];
seed?: { seed?: {
game?: ModerationGame; game?: ModerationGame;
guid?: string; guid?: string;
@@ -31,8 +51,15 @@
revision?: number; revision?: number;
matchId?: number; matchId?: number;
message?: string; 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(); } = $props();
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? '')); let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
let guid = $state(untrack(() => seed.guid ?? '')); let guid = $state(untrack(() => seed.guid ?? ''));
@@ -43,7 +70,14 @@
let duration = $state('60'); let duration = $state('60');
let reason = $state(''); let reason = $state('');
let note = $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 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');
@@ -59,7 +93,10 @@
note: 'Add private note', note: 'Add private note',
revoke: 'Revoke restriction', revoke: 'Revoke restriction',
'case-note': 'Add case note', 'case-note': 'Add case note',
resolve: 'Review case' resolve: 'Review case',
reopen: 'Reopen case',
'edit-case': 'Edit case',
evidence: 'Attach archived evidence'
}[mode] }[mode]
); );
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode)); const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
@@ -101,7 +138,8 @@
server: scope === 'server' ? server : undefined, server: scope === 'server' ? server : undefined,
reason: reason.trim(), reason: reason.trim(),
note: note.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') if (mode === 'action')
return { return {
@@ -124,7 +162,35 @@
: {}) : {})
}; };
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() }; 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') if (mode === 'revoke')
return { return {
op: 'revoke-sanction', op: 'revoke-sanction',
@@ -186,7 +252,8 @@
open = false; open = false;
onsubmitted({ onsubmitted({
game: pending.game as ModerationGame | undefined, 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) { } catch (cause) {
if (pending) uncertain = true; if (pending) uncertain = true;
@@ -211,6 +278,7 @@
><Select ><Select
bind:value={game} bind:value={game}
required required
disabled={lockedTarget}
onchange={() => (server = '')} onchange={() => (server = '')}
options={[ options={[
{ value: '', label: 'Select a game' }, { value: '', label: 'Select a game' },
@@ -229,7 +297,13 @@
label="Player GUID" label="Player GUID"
required required
hint="Use the recorded player identity. Names can change." 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}
{/if} {/if}
@@ -293,11 +367,16 @@
required capability and current policy; review enforcement coverage after required capability and current policy; review enforcement coverage after
saving. saving.
</p>{/if} </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 <Field label="Case title" required
><Input bind:value={title} required maxlength={160} /></Field ><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> <strong>Selected chat evidence · Match #{seed.matchId}</strong>
<p>{seed.message}</p> <p>{seed.message}</p>
<small <small
@@ -306,21 +385,101 @@
> >
</div>{/if} </div>{/if}
{/if} {/if}
{#if mode === 'resolve'}<Field label="Decision" {#if mode === 'edit-case'}
<div class="paired">
<Field label="Priority"
><Select ><Select
bind:value={resolution} bind:value={priority}
options={[ options={[
{ value: 'resolved', label: 'Resolved' }, { value: 'low', label: 'Low' },
{ value: 'dismissed', label: 'Dismissed' } { value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' }
]} ]}
/></Field /></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={moderationCaseOutcomes.map((value) => ({
value,
label: moderationCaseStatusLabels[value]
}))}
/></Field
>{/if} >{/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 <Field
label={mode === 'action' && ['message', 'announce'].includes(action) label={mode === 'action' && ['message', 'announce'].includes(action)
? 'Message' ? 'Message'
: mode === 'restriction' || mode === 'action' : mode === 'restriction' || mode === 'action'
? 'Player-visible reason' ? 'Player-visible reason'
: mode === 'reopen'
? 'Reopening reason'
: 'Review reason'} : 'Review reason'}
hint={gameText hint={gameText
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.` ? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
+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'; import { serverTitle } from './server-labels.js';
export function moderationCaseHref(id: string): string {
return `/moderation/cases/${encodeURIComponent(id)}`;
}
export function moderationHref(context: { export function moderationHref(context: {
game?: string; game?: string;
guid?: string; guid?: string;
@@ -35,6 +43,9 @@ export const moderationActionLabels: Record<string, string> = {
'create-case': 'Case opened', 'create-case': 'Case opened',
'case-note': 'Case note added', 'case-note': 'Case note added',
'resolve-case': 'Case reviewed', 'resolve-case': 'Case reviewed',
'reopen-case': 'Case reopened',
'update-case': 'Case updated',
'attach-evidence': 'Case evidence attached',
note: 'Private note', note: 'Private note',
'restriction.created': 'Restriction created', 'restriction.created': 'Restriction created',
'restriction.revoked': 'Restriction revoked', 'restriction.revoked': 'Restriction revoked',
@@ -47,11 +58,63 @@ export const moderationActionLabels: Record<string, string> = {
'case.note': 'Case note added', 'case.note': 'Case note added',
'case.resolved': 'Case resolved', 'case.resolved': 'Case resolved',
'case.dismissed': 'Case dismissed', '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', 'player.note': 'Private player note',
'runtime.configure': 'Integration configuration requested' 'runtime.configure': 'Integration configuration requested'
}; };
export function moderationEventDetail(detail: string, action: string): string { 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; if (!['restriction.created', 'action.queued'].includes(action)) return detail;
try { try {
const value: unknown = JSON.parse(detail); const value: unknown = JSON.parse(detail);
+8
View File
@@ -23,8 +23,16 @@ const states: Record<string, [string, StatusTone]> = {
'moderation-active': ['Active', 'warning'], 'moderation-active': ['Active', 'warning'],
'moderation-revoked': ['Revoked', 'neutral'], 'moderation-revoked': ['Revoked', 'neutral'],
'moderation-open': ['Open', 'info'], 'moderation-open': ['Open', 'info'],
'moderation-in_review': ['In review', 'info'],
'moderation-escalated': ['Escalated', 'warning'],
'moderation-resolved': ['Resolved', 'online'], 'moderation-resolved': ['Resolved', 'online'],
'moderation-dismissed': ['Dismissed', 'neutral'], '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-queued': ['Queued', 'neutral'],
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'], 'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
'moderation-confirmed': ['Confirmed', 'online'], '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);
}
};
+108 -52
View File
@@ -5,6 +5,8 @@
import { canModerate } from '$lib/auth.js'; import { canModerate } from '$lib/auth.js';
import { import {
canRevokeModerationRestriction, canRevokeModerationRestriction,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationOverview, type ModerationOverview,
type ModerationQuery, type ModerationQuery,
type ModerationGame type ModerationGame
@@ -14,7 +16,8 @@
import { import {
moderationActionLabels, moderationActionLabels,
moderationGameName, moderationGameName,
moderationEventDetail moderationEventDetail,
moderationCaseHref
} from '$ui/moderation.js'; } from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -52,9 +55,7 @@
let generation = 0; let generation = 0;
let dialogOpen = $state(false); let dialogOpen = $state(false);
let dialogKey = $state(0); let dialogKey = $state(0);
let mode = $state< let mode = $state<'restriction' | 'action' | 'case' | 'note' | 'revoke'>('restriction');
'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve'
>('restriction');
let seed = $state<{ let seed = $state<{
game?: ModerationGame; game?: ModerationGame;
guid?: string; guid?: string;
@@ -160,7 +161,15 @@
dialogKey++; dialogKey++;
dialogOpen = true; 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.'; notice = 'Saved. Enforcement and delivery status are shown below.';
cancelRefresh(); cancelRefresh();
const view = const view =
@@ -320,7 +329,15 @@
class="filters" class="filters"
onsubmit={(event) => { onsubmit={(event) => {
event.preventDefault(); 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" <Field label="Search moderation" class="search"
@@ -343,6 +360,47 @@
/></Field /></Field
> >
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></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 type="submit" disabled={busy}>Apply</Button>
<Button <Button
disabled={busy} disabled={busy}
@@ -354,7 +412,10 @@
offset: null, offset: null,
server: null, server: null,
message: null, message: null,
matchId: null matchId: null,
caseStatus: null,
priority: null,
assignedTo: null
})}>Reset</Button })}>Reset</Button
> >
</form> </form>
@@ -507,6 +568,9 @@
</dd> </dd>
</div> </div>
</dl> </dl>
{#if sanction.caseId}<a href={moderationCaseHref(sanction.caseId)}
>View linked case</a
>{/if}
{#if sanction.note}<p class="private-note"> {#if sanction.note}<p class="private-note">
<strong>Private note</strong> <strong>Private note</strong>
{sanction.note} {sanction.note}
@@ -536,47 +600,46 @@
{#if report.cases.length}<div class="record-list"> {#if report.cases.length}<div class="record-list">
{#each report.cases as item (item.id)}<article class="record"> {#each report.cases as item (item.id)}<article class="record">
<div class="record-heading"> <div class="record-heading">
<h2>{item.title}</h2> <h2><a href={moderationCaseHref(item.id)}>{item.title}</a></h2>
<StatusBadge state={`moderation-${item.status}`} /> <StatusBadge state={`moderation-${item.status}`} />
</div> </div>
<p> <p>
<a href={contextHref(playerProfileHref(item.guid), $page.url)} <a href={contextHref(playerProfileHref(item.guid), $page.url)}
>{item.guid}</a >{item.guid}</a
> >
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime · {moderationGameName(item.game)}
value={item.createdAt}
/>
</p> </p>
<details> <dl>
<summary>Evidence and staff notes ({item.evidence.length})</summary> <div>
<ol class="evidence-list"> <dt>Priority</dt>
{#each item.evidence as evidence (evidence.id)}<li> <dd>
<p>{evidence.text}</p> {item.priority.charAt(0).toUpperCase() +
<small item.priority.slice(1)}
>{evidence.source} · {evidence.actor} · <RelativeTime </dd>
value={evidence.createdAt} </div>
/></small <div>
> <dt>Assigned to</dt>
</li>{:else}<li>No evidence or notes recorded.</li>{/each} <dd>{item.assignee ?? 'Unassigned'}</dd>
</ol> </div>
</details> <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"> <div class="record-heading">
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div <span class="hint"
class="row-actions" >{item.evidence.length} evidence and staff note {item.evidence
.length === 1
? 'entry'
: 'entries'}</span
>
<Button href={moderationCaseHref(item.id)} size="sm"
>View case</Button
> >
<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}
</div> </div>
</article>{/each} </article>{/each}
</div>{:else}<EmptyState </div>{:else}<EmptyState
@@ -628,10 +691,12 @@
: ''}{event.game : ''}{event.game
? ` · ${moderationGameName(event.game)}` ? ` · ${moderationGameName(event.game)}`
: ''}</small : ''}</small
>{#if event.subjectId}<CopyValue >{#if event.subjectId}
value={event.subjectId} {#if event.action.startsWith('case.')}<a
label="Record ID" href={moderationCaseHref(event.subjectId)}>View case</a
/>{/if} >{/if}
<CopyValue value={event.subjectId} label="Record ID" />
{/if}
</li>{/each} </li>{/each}
</ol>{:else}<EmptyState </ol>{:else}<EmptyState
title="No history found" title="No history found"
@@ -788,15 +853,6 @@
.private-note { .private-note {
font-size: var(--text-sm); 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 { .audit-list {
list-style: none; list-style: none;
padding: 0; 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>
+500
View File
@@ -0,0 +1,500 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import CasePage from '../../src/routes/moderation/cases/[id]/+page.svelte';
import type { ModerationCaseDetail } from '../../src/lib/moderation.js';
import type { UserRole } from '../../src/lib/auth.js';
const navigation = vi.hoisted(() => ({ before: [] as (() => void)[] }));
const currentPage = writable({
url: new URL('http://localhost/moderation/cases/case-1'),
data: { user: { id: 'staff-1', role: 'moderator' as UserRole } }
});
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
},
navigating: writable(null)
}));
vi.mock('$app/navigation', () => ({
beforeNavigate: (fn: () => void) => navigation.before.push(fn)
}));
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
navigation.before.length = 0;
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const guid = '18446744073709551615';
function detail(): ModerationCaseDetail {
return {
case: {
id: 'case-1',
game: 't5',
guid,
title: 'Repeated chat abuse',
status: 'in_review',
priority: 'high',
assigneeId: 'staff-1',
assignee: 'Moderator One',
actor: 'Reporter',
createdAt: '2026-10-15T12:00:00Z',
updatedAt: '2026-10-16T12:00:00Z',
revision: 4,
evidence: [
{
id: 'evidence-1',
text: 'abusive message',
source: 'Archived chat · theater · match 17',
actor: 'Reporter',
createdAt: '2026-10-16T12:00:00Z',
snapshot: JSON.stringify({
matchId: 17,
match: {
game: 't5',
server: 't5_zombie_theater_1',
started_at: '2026-10-15T12:00:00Z'
},
entry: {
type: 'message',
message: {
guid,
player: 'Tank',
time: '12:34',
message: 'abusive message'
},
context: { realTime: '2026-10-15T12:12:34Z', round: 9 }
}
})
},
{
id: 'note-1',
text: 'Check the earlier warning.',
source: 'Staff note',
actor: 'Moderator One',
createdAt: '2026-10-16T12:00:00Z'
}
]
},
player: {
game: 't5',
guid,
name: 'Dempsey',
aliases: ['Tank'],
firstSeen: null,
lastSeen: null,
servers: [],
restrictions: 1
},
sanctions: [
{
id: 'restriction-1',
game: 't5',
guid,
kind: 'mute',
scope: 'game',
server: null,
reason: 'Prior warning ignored',
note: '',
createdAt: '2026-10-15T12:00:00Z',
expiresAt: '2026-10-16T12:00:00Z',
revokedAt: null,
actorId: 'staff-1',
actor: 'Moderator One',
revision: 2,
active: true,
caseId: 'case-1'
}
],
events: [
{
id: 1,
game: 't5',
guid,
action: 'case.opened',
subjectId: 'case-1',
actor: 'Reporter',
detail: 'Repeated chat abuse',
createdAt: '2026-10-15T12:00:00Z'
}
],
coverage: [
{
server: 't5_zombie_theater_1',
game: 't5',
connectedAt: null,
revision: 1,
desiredRevision: 2,
state: 'pending',
capabilities: ['ban', 'mute']
}
],
assignees: [
{ id: 'staff-1', username: 'Moderator One' },
{ id: 'staff-2', username: 'Moderator Two' }
],
checkedAt: '2026-10-16T12:00:00Z'
};
}
function show(initial = detail(), role: UserRole = 'moderator') {
const data = writable({ moderationCase: initial });
const state = fromStore(data);
currentPage.set({
url: new URL(`http://localhost/moderation/cases/${initial.case.id}`),
data: { user: { id: 'staff-1', role } }
});
component = mount(CasePage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: ModerationCaseDetail) => {
data.set({ moderationCase: next });
flushSync();
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
label: string
): T {
const labelNode = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
return document.querySelector(`[aria-labelledby="${labelNode.id}"]`)! as T;
}
function enter(node: HTMLInputElement | HTMLTextAreaElement, value: string) {
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
function select(label: string, value: string) {
const node = field<HTMLSelectElement>(label);
// happy-dom's :checked selector matches inputs but not selected options.
const originalQuery = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: originalQuery(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
function submit() {
document
.querySelector('dialog form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
function response(value: unknown, status = 200) {
return new Response(JSON.stringify(value), { status });
}
it('preserves original evidence context, linked restrictions, and staff history for read-only viewers', () => {
show(detail(), 'super_viewer');
expect(document.body.textContent).toContain('Read-only moderation access');
expect(document.body.textContent).toContain('Round 9');
expect(document.body.textContent).toContain('Original log time · 12:34');
expect(document.body.textContent).toContain('Check the earlier warning.');
expect(document.body.textContent).toContain('Prior warning ignored');
expect(document.body.textContent).toContain('Policy pending');
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
for (const label of [
'Edit case',
'Review case',
'Reopen case',
'Restrict player',
'Revoke restriction',
'Add case note',
'Attach evidence'
])
expect(button(label)).toBeUndefined();
expect(document.querySelector('dialog')).toBeNull();
});
it('locks case restrictions to the recorded identity and revision without closing the case', async () => {
const updated = detail();
updated.case.revision = 5;
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'new-restriction' }))
.mockResolvedValue(response(updated));
vi.stubGlobal('fetch', fetch);
show();
button('Restrict player').click();
await tick();
expect(field<HTMLSelectElement>('Game').disabled).toBe(true);
expect(field<HTMLInputElement>('Player GUID').readOnly).toBe(true);
expect(field<HTMLInputElement>('Player GUID').value).toBe(guid);
expect(document.querySelector('dialog')!.textContent).toContain('does not close the case');
enter(field('Player-visible reason'), 'Repeated abuse');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'create-sanction',
game: 't5',
guid,
caseId: 'case-1',
caseRevision: 4,
reason: 'Repeated abuse',
durationMinutes: 60
});
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
expect(button('Review case')).toBeDefined();
});
it('records specific outcomes and reopens a closed case with a reason and the latest revision', async () => {
const closed = detail();
closed.case.status = 'warning_issued';
closed.case.revision = 5;
const reopened = detail();
reopened.case.status = 'open';
reopened.case.revision = 6;
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValueOnce(response(closed))
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValueOnce(response(reopened));
vi.stubGlobal('fetch', fetch);
show();
button('Review case').click();
await tick();
expect([...field<HTMLSelectElement>('Outcome').options].map((option) => option.value)).toEqual([
'resolved',
'dismissed',
'warning_issued',
'restriction_applied',
'no_action',
'duplicate',
'appeal_upheld',
'appeal_denied'
]);
select('Outcome', 'warning_issued');
enter(field('Review reason'), 'Warning was sufficient');
submit();
await vi.waitFor(() => expect(button('Reopen case')).toBeDefined());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'resolve-case',
id: 'case-1',
revision: 4,
status: 'warning_issued',
reason: 'Warning was sufficient'
});
expect(button('Restrict player')).toBeUndefined();
button('Reopen case').click();
await tick();
enter(field('Reopening reason'), 'New evidence received');
submit();
await vi.waitFor(() => expect(button('Review case')).toBeDefined());
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
op: 'reopen-case',
id: 'case-1',
revision: 5,
reason: 'New evidence received'
});
expect(document.body.textContent).toContain('Check the earlier warning.');
});
it('edits priority, assignment, and review workflow with a required transition reason', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValue(response(detail()));
vi.stubGlobal('fetch', fetch);
show();
button('Edit case').click();
await tick();
enter(field('Case title'), 'Urgent report');
select('Priority', 'urgent');
select('Assigned to', 'staff-2');
select('Workflow status', 'escalated');
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(true);
enter(field('Change reason'), 'Needs administrator review');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'update-case',
id: 'case-1',
revision: 4,
title: 'Urgent report',
priority: 'urgent',
assigneeId: 'staff-2',
status: 'escalated',
reason: 'Needs administrator review'
});
});
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
const initial = detail();
initial.case.status = 'no_action';
initial.case.assigneeId = 'disabled-staff';
initial.case.assignee = 'Former staff';
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValue(response(initial));
vi.stubGlobal('fetch', fetch);
show(initial);
button('Edit case').click();
await tick();
expect(field<HTMLSelectElement>('Assigned to').value).toBe('disabled-staff');
expect(document.querySelector('dialog')!.textContent).toContain('Former staff (unavailable)');
enter(field('Case title'), 'Clarified title');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
const payload = JSON.parse(fetch.mock.calls[0][1].body);
expect(payload.title).toBe('Clarified title');
expect(payload).not.toHaveProperty('assigneeId');
expect(payload).not.toHaveProperty('status');
});
it('attaches archived evidence by match and exact message with revision checks', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValue(response(detail()));
vi.stubGlobal('fetch', fetch);
show();
button('Attach evidence').click();
await tick();
enter(field('Match ID'), '17');
enter(field('Exact archived message'), ' abusive message ');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'attach-evidence',
id: 'case-1',
revision: 4,
evidence: { matchId: 17, message: ' abusive message ' }
});
});
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
const updated = detail();
updated.case.revision = 8;
updated.case.status = 'escalated';
const fetch = vi
.fn()
.mockResolvedValueOnce(
response({ error: 'This case changed. Reload it before making another change.' }, 409)
)
.mockResolvedValue(response(updated));
vi.stubGlobal('fetch', fetch);
show();
button('Review case').click();
await tick();
select('Outcome', 'appeal_denied');
enter(field('Review reason'), 'Evidence supports the restriction');
submit();
await vi.waitFor(() =>
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
'This case changed.'
)
);
expect(field<HTMLSelectElement>('Outcome').value).toBe('appeal_denied');
expect(document.querySelector('dialog')!.open).toBe(true);
expect(button('Reopen case')).toBeUndefined();
button('Cancel').click();
await tick();
button('Refresh').click();
await vi.waitFor(() =>
expect(document.querySelector('.case-summary')?.textContent).toContain('Escalated')
);
button('Review case').click();
await tick();
enter(field('Review reason'), 'Reviewed new context');
submit();
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
});
it('retries uncertain case notes with the exact same idempotent request', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValue(response(detail()));
vi.stubGlobal('fetch', fetch);
show();
button('Add case note').click();
await tick();
enter(field('Private staff note'), 'Follow up on appeal');
submit();
await vi.waitFor(() =>
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true)
);
button('Retry same request').click();
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2));
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'case-note',
id: 'case-1',
revision: 4,
text: 'Follow up on appeal',
idempotencyKey: expect.any(String)
});
});
it('keeps last loaded records when refresh fails and rejects an old response after changing cases', async () => {
let resolve!: (response: Response) => void;
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Offline'))
.mockImplementationOnce(
() =>
new Promise<Response>((done) => {
resolve = done;
})
);
vi.stubGlobal('fetch', fetch);
const update = show();
button('Refresh').click();
await vi.waitFor(() => expect(document.body.textContent).toContain('Case updates paused.'));
expect(document.querySelector('h1')!.textContent).toBe('Repeated chat abuse');
button('Retry').click();
navigation.before.forEach((fn) => fn());
const next = detail();
next.case.id = 'case-2';
next.case.title = 'Another case';
update(next);
resolve(response(detail()));
await tick();
await tick();
expect(document.querySelector('h1')!.textContent).toBe('Another case');
});
it('enforces role and ownership limits on linked restriction revocation', async () => {
const initial = detail();
initial.sanctions[0].actorId = 'staff-2';
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'restriction-1' }))
.mockResolvedValue(response(initial));
vi.stubGlobal('fetch', fetch);
show(initial);
expect(button('Revoke restriction')).toBeUndefined();
currentPage.update((value) => ({ ...value, data: { user: { id: 'staff-1', role: 'admin' } } }));
flushSync();
button('Revoke restriction').click();
await tick();
enter(field('Review reason'), 'Appeal upheld');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'revoke-sanction',
id: 'restriction-1',
revision: 2,
reason: 'Appeal upheld'
});
});
+57
View File
@@ -303,3 +303,60 @@ it('copies selected archived chat evidence separately from staff notes', async (
evidence: { matchId: 17, message: 'abusive message' } evidence: { matchId: 17, message: 'abusive message' }
}); });
}); });
it('opens a newly created case page after preserving selected evidence', async () => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ ok: true, id: 'new-case' })));
vi.stubGlobal('fetch', fetch);
show();
button('Open case').click();
await tick();
const inputs = document.querySelectorAll<HTMLInputElement>('dialog input');
enter(inputs[inputs.length - 1], 'Appeal review');
submitDialog();
await vi.waitFor(() =>
expect(navigation.goto).toHaveBeenCalledWith('/moderation/cases/new-case', {
invalidateAll: true
})
);
});
it('links case records to workspaces and preserves case filters while paging', () => {
const data = overview('cases');
data.query = { ...data.query, caseStatus: 'active', priority: 'high', assignedTo: 'me' };
data.cases = [
{
id: 'case-1',
game: 't5',
guid,
title: 'Chat investigation',
status: 'escalated',
priority: 'high',
assigneeId: 'staff-1',
assignee: 'Moderator',
actor: 'Reporter',
createdAt: '2026-10-15T12:00:00Z',
updatedAt: '2026-10-16T12:00:00Z',
revision: 3,
evidence: []
}
];
data.total = 40;
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me');
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
'Chat investigation'
);
expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
button('Next').click();
const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
expect(destination.searchParams.get('caseStatus')).toBe('active');
expect(destination.searchParams.get('priority')).toBe('high');
expect(destination.searchParams.get('assignedTo')).toBe('me');
expect(destination.searchParams.get('offset')).toBe('25');
button('Reset').click();
const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'offset'])
expect(reset.searchParams.has(filter)).toBe(false);
});
+2
View File
@@ -21,7 +21,9 @@ describe('management route permissions', () => {
it.each([ it.each([
'/moderation', '/moderation',
'/moderation/players/[guid]', '/moderation/players/[guid]',
'/moderation/cases/[id]',
'/api/moderation', '/api/moderation',
'/api/moderation/cases/[id]',
'/api/moderation/players/[guid]', '/api/moderation/players/[guid]',
'/api/moderation/sanctions/[id]/revoke' '/api/moderation/sanctions/[id]/revoke'
])('limits private moderation access at %s without changing fleet privileges', (route) => { ])('limits private moderation access at %s without changing fleet privileges', (route) => {
+24 -7
View File
@@ -290,21 +290,38 @@ describe('browser authentication guard', () => {
} }
} }
); );
it('protects moderation pages and Svelte page-data requests with the same role checks', async () => { it.each([
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) { ['/moderation', '/moderation'],
signIn(role); ['/moderation/cases/[id]', '/moderation/cases/case-123']
for (const path of ['/moderation', '/moderation/__data.json']) { ])(
const call = requestFor('/moderation', { path }); 'protects %s pages and Svelte page-data requests with the same role checks',
async (route, path) => {
for (const role of [
'viewer',
'super_viewer',
'operator',
'moderator',
'admin'
] as const) {
const session = signIn(role);
for (const requestPath of [path, `${path}/__data.json`]) {
for (const method of ['GET', 'HEAD']) {
const call = requestFor(route, { path: requestPath, method });
if (role === 'viewer' || role === 'operator') { if (role === 'viewer' || role === 'operator') {
await expect(call.invoke()).rejects.toMatchObject({ status: 403 }); await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
expect(call.resolve).not.toHaveBeenCalled(); expect(call.resolve).not.toHaveBeenCalled();
} else { } else {
expect((await call.invoke()).status).toBe(200); const response = await call.invoke();
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toContain('no-store');
expect(call.resolve).toHaveBeenCalledOnce(); expect(call.resolve).toHaveBeenCalledOnce();
expect(call.event.locals.user).toEqual(session.user);
} }
} }
} }
}); }
}
);
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])( it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
'permits only administrators to write settings and Super Viewers to read them', 'permits only administrators to write settings and Super Viewers to read them',
async (method) => { async (method) => {
@@ -0,0 +1,69 @@
import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
const { db } = await import('../../src/lib/server/db/index.js');
for (const id of ['first', 'second'])
db()
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused','moderator',0,0)`
)
.run(id, id, id);
});
afterEach(async () => fixture.cleanup());
it('filters case queues by workflow, priority and ownership while prioritizing active work', async () => {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const { db } = await import('../../src/lib/server/db/index.js');
const ids: string[] = [];
for (const [title, priority, status, owner] of [
['Normal report', 'normal', 'open', null],
['Urgent review', 'urgent', 'in_review', 'first'],
['High escalation', 'high', 'escalated', 'second'],
['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first']
]) {
const { id } = mutateModeration('first', {
op: 'create-case',
game: 't5',
guid: '123',
title,
idempotencyKey: randomUUID()
});
ids.push(id);
db()
.prepare('UPDATE moderation_cases SET priority=?,status=?,assignee_id=? WHERE id=?')
.run(priority, status, owner, id);
}
const read = (extra = '') =>
moderationOverview('first', moderationQuery(new URLSearchParams(`view=cases&${extra}`)));
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
expect(read('caseStatus=active').total).toBe(3);
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
expect(read('caseStatus=escalated&priority=high').cases.map((item) => item.id)).toEqual([
ids[2]
]);
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
expect(read('assignedTo=unassigned').cases.map((item) => item.id)).toEqual([ids[0]]);
expect(read('assignedTo=second').cases[0].assignee).toBe('second');
expect(
read('caseStatus=active&assignedTo=me&priority=urgent&game=t5&guid=123&q=review&offset=100')
.query.offset
).toBe(0);
expect(read('caseStatus=active&assignedTo=me&priority=urgent&game=t6').total).toBe(0);
expect(read('caseStatus=invalid&priority=invalid').total).toBe(4);
expect(
moderationOverview(
'first',
moderationQuery(
new URLSearchParams('caseStatus=closed&assignedTo=unassigned&priority=urgent')
)
).total
).toBe(1);
});
+120
View File
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../src/lib/auth.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer']) {
db()
.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);
}
db()
.prepare(
`INSERT INTO moderation_cases
(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('case-123','t5','123','Private investigation','open','moderator','moderator',?,?)`
)
.run('2026-10-17T12:00:00.000Z', '2026-10-17T12:00:00.000Z');
});
afterEach(async () => fixture.cleanup());
async function api(user: AuthUser | null, id = 'case-123') {
const { GET } = await import('../../src/routes/api/moderation/cases/[id]/+server.js');
return GET({ locals: { user }, params: { id } } as Parameters<typeof GET>[0]);
}
async function page(user: AuthUser | null, id = 'case-123') {
const { load } = await import('../../src/routes/moderation/cases/[id]/+page.server.js');
const setHeaders = vi.fn();
const result = await load({
locals: { user },
params: { id },
setHeaders
} as unknown as Parameters<typeof load>[0]);
return { result, setHeaders };
}
it.each(['admin', 'moderator', 'super_viewer'])(
'serves private case detail without caching to %s',
async (role) => {
const { getUser } = await import('../../src/lib/server/auth/index.js');
const user = getUser(role)!;
const response = await api(user);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toMatchObject({
case: { id: 'case-123', title: 'Private investigation' }
});
const { result, setHeaders } = await page(user);
expect(result).toMatchObject({
moderationCase: { case: { id: 'case-123', title: 'Private investigation' } }
});
expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
}
);
it.each(['operator', 'viewer'])(
'rejects %s reads before revealing case existence or content',
async (role) => {
const { getUser } = await import('../../src/lib/server/auth/index.js');
const user = getUser(role)!;
for (const id of ['case-123', 'missing']) {
const response = await api(user, id);
expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toEqual({ error: 'Moderation access is required.' });
await expect(page(user, id)).rejects.toMatchObject({
status: 403,
body: { message: 'Moderation access is required.' }
});
}
}
);
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
'rechecks current staff access when stale session locals have %s',
async (change) => {
const { getUser } = await import('../../src/lib/server/auth/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const user = getUser('moderator')!;
db().prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
const response = await api(user);
expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
await expect(page(user)).rejects.toMatchObject({ status: 403 });
}
);
it('preserves private not-found responses in the API and page loader', async () => {
const { getUser } = await import('../../src/lib/server/auth/index.js');
const user = getUser('moderator')!;
const response = await api(user, 'missing');
expect(response.status).toBe(404);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toEqual({ error: 'Case not found.' });
await expect(page(user, 'missing')).rejects.toMatchObject({
status: 404,
body: { message: 'Case not found.' }
});
});
it('requires a signed-in user before looking up a case', async () => {
const response = await api(null);
expect(response.status).toBe(401);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toEqual({ error: 'Sign in to continue.' });
await expect(page(null)).rejects.toMatchObject({
status: 401,
body: { message: 'Sign in to continue.' }
});
});
+455
View File
@@ -0,0 +1,455 @@
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';
import { moderationCaseOutcomes } from '../../src/lib/moderation.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
const server = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([server]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'viewer', 'operator', 'super_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 chat conduct',
...overrides
});
const restriction = (
caseId: string,
caseRevision: number,
overrides: Record<string, unknown> = {}
) => ({
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'mute',
scope: 'game',
reason: 'Repeated chat abuse',
durationMinutes: 60,
caseId,
caseRevision,
...overrides
});
it('upgrades legacy cases without changing evidence, events, requests, or restriction records', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const { migrateModerationCases } = await import('../../src/lib/server/moderation/schema.js');
const legacy = new Database(':memory:');
try {
legacy.pragma('foreign_keys = ON');
runMigrations(legacy, migrations.slice(0, 28));
for (const status of ['open', 'resolved', 'dismissed']) {
legacy
.prepare('INSERT INTO moderation_cases VALUES(?,?,?,?,?,?,?,?,?,?)')
.run(
status,
't5',
'123',
`Legacy ${status}`,
status,
'retired-user',
'Former staff',
clock,
clock,
7
);
legacy
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(
`evidence-${status}`,
status,
'Original evidence',
'Archived chat',
'{"original":true}',
'retired-user',
'Former staff',
clock
);
legacy
.prepare(
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
VALUES('t5','123','Former staff','case.opened',?,'Original history',?)`
)
.run(status, clock);
}
legacy
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
.run(
'retired-user',
'original-request',
'fingerprint',
'{"ok":true,"id":"open"}',
clock
);
legacy
.prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at)
VALUES('old-sanction','t5','123','ban','game','Original reason','','retired-user','Former staff',?)`
)
.run(clock);
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
migrateModerationCases(legacy);
expect(legacy.pragma('foreign_keys', { simple: true })).toBe(1);
expect(legacy.pragma('foreign_key_check')).toEqual([]);
expect(
legacy
.prepare(
'SELECT status,revision,priority,assignee_id FROM moderation_cases ORDER BY id'
)
.all()
).toEqual(
['dismissed', 'open', 'resolved'].map((status) => ({
status,
revision: 7,
priority: 'normal',
assignee_id: null
}))
);
expect(
legacy
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE snapshot=?')
.get('{"original":true}')
).toEqual({ count: 3 });
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 3
});
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
result: '{"ok":true,"id":"open"}'
});
expect(legacy.prepare('SELECT id,case_id FROM moderation_sanctions').get()).toEqual({
id: 'old-sanction',
case_id: null
});
legacy.prepare("UPDATE moderation_cases SET status='in_review' WHERE id='open'").run();
expect(() =>
legacy.prepare("UPDATE moderation_sanctions SET case_id='missing'").run()
).toThrow('FOREIGN KEY');
expect(() => legacy.prepare("DELETE FROM moderation_cases WHERE id='open'").run()).toThrow(
'FOREIGN KEY'
);
} finally {
legacy.close();
}
});
it('requires private read access and returns a complete case outside list pagination', async () => {
const { id } = await create({ note: 'Initial context' });
for (const actor of ['viewer', 'operator'])
await expect(detail(id, actor)).rejects.toThrow('Moderation access');
await expect(detail('missing')).rejects.toMatchObject({ status: 404 });
for (let i = 0; i < 30; i++) await mutate({ op: 'case-note', id, text: `Follow-up ${i}` });
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({
id,
priority: 'normal',
assigneeId: null,
assignee: null,
revision: 31
});
expect(data.case.evidence).toHaveLength(31);
expect(data.events).toHaveLength(31);
expect(data.player).toMatchObject({ game: 't5', guid: '123' });
expect(data.assignees).toEqual([
{ id: 'admin', username: 'admin' },
{ id: 'moderator', username: 'moderator' }
]);
await expect(
mutate({ op: 'case-note', id, text: 'Forbidden write' }, 'super_viewer')
).rejects.toThrow('Moderation access');
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run();
await expect(detail(id)).rejects.toThrow('Moderation access');
});
it('audits title, ownership, priority and active state updates with optimistic revisions', async () => {
const { id } = await create();
await expect(
mutate({ op: 'update-case', id, revision: 1, status: 'in_review' })
).rejects.toThrow('Status change reason');
await mutate({
op: 'update-case',
id,
revision: 1,
title: 'Escalated chat report',
priority: 'urgent',
assigneeId: 'admin',
status: 'in_review',
reason: 'Reviewing archive'
});
const data = await detail(id);
expect(data.case).toMatchObject({
title: 'Escalated chat report',
priority: 'urgent',
assigneeId: 'admin',
assignee: 'admin',
status: 'in_review',
revision: 2
});
expect(JSON.parse(data.events[0].detail)).toMatchObject({
title: { from: 'Review chat conduct', to: 'Escalated chat report' },
assignee: { from: null, to: 'admin' },
status: { from: 'open', to: 'in_review' },
reason: 'Reviewing archive'
});
await expect(mutate({ op: 'update-case', id, revision: 1, priority: 'low' })).rejects.toThrow(
'changed'
);
for (const assigneeId of ['viewer', 'super_viewer', 'operator', 'missing'])
await expect(mutate({ op: 'update-case', id, revision: 2, assigneeId })).rejects.toThrow(
'active administrator or moderator'
);
handle.prepare("UPDATE auth_users SET must_change_password=1 WHERE id='admin'").run();
await mutate({
op: 'update-case',
id,
revision: 2,
assigneeId: 'admin',
title: 'Existing ownership retained'
});
await mutate({
op: 'update-case',
id,
revision: 3,
assigneeId: null,
status: 'escalated',
reason: 'Needs administrator review'
});
expect((await detail(id)).case).toMatchObject({
revision: 4,
status: 'escalated',
assigneeId: null
});
await expect(
mutate({ op: 'update-case', id, revision: 4, assigneeId: 'admin' })
).rejects.toThrow('active administrator or moderator');
});
it('supports every outcome and reopening while preserving each prior decision', async () => {
const { id } = await create();
let revision = 1;
for (const status of moderationCaseOutcomes) {
await expect(mutate({ op: 'resolve-case', id, revision, status })).rejects.toThrow(
'Decision reason'
);
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
revision++;
expect((await detail(id)).case.status).toBe(status);
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
'Use reopen'
);
await expect(
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
).rejects.toThrow('changed');
await expect(mutate({ op: 'reopen-case', id, revision })).rejects.toThrow(
'Reopening reason'
);
await mutate({ op: 'reopen-case', id, revision, reason: `New evidence after ${status}` });
revision++;
}
const data = await detail(id);
expect(data.case).toMatchObject({ revision, status: 'open' });
for (const status of moderationCaseOutcomes) {
expect(data.case.evidence.some((item) => item.text === `Decision ${status}`)).toBe(true);
expect(data.events.some((event) => event.action === `case.${status}`)).toBe(true);
}
await expect(
mutate({ op: 'reopen-case', id, revision, reason: 'Already open' })
).rejects.toThrow('already active');
await expect(
mutate({ op: 'resolve-case', id, revision, status: 'escalated', reason: 'Wrong operation' })
).rejects.toThrow('case outcome');
});
it('links restrictions atomically with case revisions and idempotent retries', async () => {
const { id } = await create();
const input = { ...restriction(id, 1), idempotencyKey: randomUUID() };
for (const override of [
{ game: 't6' },
{ guid: '456' },
{ durationMinutes: null },
{ caseRevision: 0 }
])
await expect(mutate({ ...input, ...override })).rejects.toThrow();
expect((await detail(id)).case.revision).toBe(1);
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
count: 0
});
const applied = await mutate(input);
expect(await mutate(input)).toEqual(applied);
let data = await detail(id);
expect(data.case.revision).toBe(2);
expect(data.sanctions).toMatchObject([{ id: applied.id, caseId: id, active: true }]);
expect(data.events.filter((event) => event.action === 'case.restriction')).toHaveLength(1);
await expect(mutate(restriction(id, 1))).rejects.toThrow('changed');
await mutate({ op: 'revoke-sanction', id: applied.id, revision: 1, reason: 'Review revised' });
data = await detail(id);
expect(data.case.revision).toBe(3);
expect(data.sanctions[0].active).toBe(false);
expect(data.events[0]).toMatchObject({
action: 'restriction.revoked',
subjectId: applied.id,
detail: 'Review revised'
});
await mutate({
op: 'resolve-case',
id,
revision: 3,
status: 'restriction_applied',
reason: 'Finished review'
});
await expect(mutate(restriction(id, 4))).rejects.toThrow('Reopen this case');
});
it('rolls back restriction, policy and case writes when a linked audit write fails', async () => {
const { id } = await create();
const policy = handle.prepare('SELECT revision FROM moderation_policy').get();
handle.exec(`CREATE TRIGGER fail_case_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.restriction' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const input = { ...restriction(id, 1), idempotencyKey: randomUUID() };
await expect(mutate(input)).rejects.toThrow('audit unavailable');
expect((await detail(id)).case.revision).toBe(1);
expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual(policy);
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
count: 0
});
expect(
handle
.prepare('SELECT COUNT(*) AS count FROM moderation_requests WHERE request_key=?')
.get(input.idempotencyKey)
).toEqual({ count: 0 });
handle.exec('DROP TRIGGER fail_case_audit');
await mutate(input);
expect((await detail(id)).sanctions).toHaveLength(1);
});
it('attaches only archived evidence for the exact player and retains snapshots after cleanup', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({
server,
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Player' }],
receivedAt: clock
});
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
id: number;
};
for (const [guid, message] of [
['123', 'Verified message'],
['123', ' Evidence with padding '],
['456', 'Another player']
])
handle
.prepare('INSERT INTO match_chat VALUES(?,?,?,?)')
.run(
matchId,
message,
0,
JSON.stringify({ type: 'message', message: { guid, message, player: 'Player' } })
);
const { id } = await create();
for (const message of ['Forged text', 'Another player', 'Evidence with padding'])
await expect(
mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, message } })
).rejects.toThrow('could not be verified');
const { id: otherGame } = await create({ game: 't6' });
await expect(
mutate({
op: 'attach-evidence',
id: otherGame,
revision: 1,
evidence: { matchId, message: 'Verified message' }
})
).rejects.toThrow('source match is unavailable');
const input = {
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, message: 'Verified message' },
idempotencyKey: randomUUID()
};
await mutate(input);
await mutate(input);
await expect(mutate({ ...input, idempotencyKey: randomUUID() })).rejects.toThrow('changed');
await mutate({
op: 'attach-evidence',
id,
revision: 2,
evidence: { matchId, message: ' Evidence with padding ' }
});
handle.prepare('DELETE FROM leaderboard_matches').run();
const data = await detail(id);
expect(data.case.revision).toBe(3);
expect(data.case.evidence).toMatchObject([
{ text: 'Verified message', snapshot: expect.stringContaining('Verified message') },
{
text: ' Evidence with padding ',
snapshot: expect.stringContaining(' Evidence with padding ')
}
]);
expect(data.events[0].action).toBe('case.evidence_attached');
});
it('keeps decision history writable when a case has reached its evidence limit', async () => {
const { id } = await create();
for (let index = 0; index < 100; index++)
handle
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(
randomUUID(),
id,
`Evidence ${index}`,
'Staff note',
null,
'moderator',
'moderator',
clock
);
await expect(
mutate({ op: 'case-note', id, revision: 1, text: 'Over the limit' })
).rejects.toThrow('100 evidence entries');
await mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'no_action',
reason: 'Review complete'
});
await mutate({ op: 'reopen-case', id, revision: 2, reason: 'Reconsider the decision' });
expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 });
expect((await detail(id)).case.evidence).toHaveLength(102);
});
+39 -1
View File
@@ -3,7 +3,7 @@ import { render } from 'svelte/server';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte'; import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte'; import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
import { moderationHref } from '../../src/lib/ui/moderation.js'; import { moderationEventDetail, moderationHref } from '../../src/lib/ui/moderation.js';
import type { UserRole } from '../../src/lib/auth.js'; import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({ const currentPage = writable({
@@ -72,3 +72,41 @@ it('does not invent a game namespace for an unscoped profile', () => {
expect(target.searchParams.get('guid')).toBe('42'); expect(target.searchParams.get('guid')).toBe('42');
expect(target.searchParams.has('game')).toBe(false); expect(target.searchParams.has('game')).toBe(false);
}); });
it('presents case decisions and ownership changes as readable history while preserving staff text', () => {
expect(
moderationEventDetail(
JSON.stringify({
title: { from: 'open', to: 'constructor' },
assignee: { from: 'open', to: 'constructor' }
}),
'case.updated'
)
).toBe('Title: open → constructor\nAssigned to: open → constructor');
expect(
moderationEventDetail(
JSON.stringify({
title: { from: 'Report', to: 'Appeal review' },
assignee: { from: null, to: 'Moderator' },
assigneeId: { from: null, to: 'private-internal-user-id' },
status: { from: 'open', to: 'in_review' },
reason: 'Reviewing additional evidence'
}),
'case.updated'
)
).toBe(
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In review\nReviewing additional evidence'
);
expect(
moderationEventDetail(
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
'case.reopened'
)
).toBe('Previous outcome: Appeal denied\nNew evidence');
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
'Decision with {unusual text}'
);
expect(
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
).toBe('2 archived chat entries attached from match #42.');
});