This commit is contained in:
+37
-11
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.`
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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.' }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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.');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user