Compare commits

..
7 Commits
65 changed files with 7065 additions and 161 deletions
+70 -4
View File
@@ -139,6 +139,38 @@ reason for workflow changes. Close a case with one of these outcomes: **Resolved
reason. Reopen a closed case when new evidence or an appeal needs review; earlier 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. decisions remain in its history. Editing case details does not reopen a case.
Use **Edit assessment** to keep a concise current summary, list the applicable
rules, and record a finding for each concern: hate speech, harassment, threats,
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
the evidence; the case's workflow status and final outcome remain separate.
Every saved assessment retains its author, timestamp, and complete previous
revision. Assessments can be revised after closure without reopening the case.
Before opening a case, review existing cases for the same game and GUID. A case
also shows same-player candidates; a shared archived match is a useful potential
duplicate signal, not a finding that two reports are identical. Link cases as
**Same incident**, **Appeal of**, or **Duplicate of**, recording why they belong
together. Same-incident links may include different players; appeals and duplicates
must concern the same game and player. Links appear from both cases with their
direction made explicit. Self-links and circular appeal/duplicate chains are
rejected. Removing a mistaken relationship requires a reason and preserves its
history. Relationship changes do not close or reopen either case.
Schedule a follow-up using a date and time, or **Review when a linked mute expires**.
Custom date entry uses your browser's local time; displayed dates follow your time
zone preference. Reviews remain scheduled after a case closes or reopens. Use
**Overdue reviews** to find due work across open and closed cases, and clear the
follow-up with a reason when the review is complete.
In-app case updates notify the assignee and case creator when another staff member
adds evidence or a staff note. Assignment changes notify the previous and new assignees
and the creator, excluding the person making the change. **Your unread updates**
is personal to your account. Open a case to review and mark existing updates
read; notifications arriving afterward stay unread. Queue and case pages refresh
every 15 seconds while visible. These are in-app notifications, with no email or
external messaging delivery.
Apply a ban or mute directly from an active case. The case fixes the target game 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 and GUID and records the resulting restriction alongside the evidence. The same
role, scope, and duration limits apply as elsewhere in Moderation. Applying a role, scope, and duration limits apply as elsewhere in Moderation. Applying a
@@ -149,13 +181,47 @@ the required permission and reason, including after a case closes. Reopening a
case also leaves its restrictions in place. Check enforcement coverage after case also leaves its restrictions in place. Check enforcement coverage after
applying or revoking a restriction. applying or revoking a restriction.
Staff notes are explicitly labeled as notes. Selecting archived chat when opening Use **Link existing action** to associate a warning, kick, private message, ban, or
a case, or attaching a match ID and exact message within an existing case, verifies mute already recorded for this player with the case. This preserves the original
the target GUID and original message against the recorded match and saves an action, author, time, delivery state, and expiry; it does not issue another action
independent snapshot, including the original timing context. Unknown or estimated or extend a restriction. The same action can support more than one related case.
Linking requires the current case revision and a reason, and appears in its timeline.
When closing with **Warning issued** or **Restriction applied**, optionally reference
one of the linked records. Warning references require a confirmed warning; failed
or uncertain delivery remains visible without being presented as a confirmed warning.
Staff notes are explicitly labeled as notes. In match chat, select up to 100
messages and choose **Attach to case** to open a case or add them to an existing
case for the selected subject. Select at least one message from that subject;
messages from other participants can be selected alongside it. Each attachment
preserves selected messages and, by default, three surrounding messages on either side, with
names, GUIDs, original timing, and highlighted subject messages. The selection
preview shows the excerpt before saving. Server verification uses stable archived
message identifiers; repeated text remains separate occurrences, and attaching
the same selected messages again does not duplicate evidence. Exact-text
attachments remain available and include every matching occurrence, without the
former ten-occurrence limit.
Snapshots remain readable after source-match cleanup. Unknown or estimated
timestamps remain such; the snapshot does not turn them into exact observations. timestamps remain such; the snapshot does not turn them into exact observations.
Unarchived or already deleted chat cannot be attached as verified evidence. Unarchived or already deleted chat cannot be attached as verified evidence.
Open **Chat archive** from Moderation to include all recorded matches, including
round-one games, interrupted games, and games missing an end report. Archive
access requires moderation read permission. Filter by server, game, map, status,
or date and open a match to select its preserved messages. Incomplete matches
show their observed state rather than final results. Ordinary match history and
leaderboard qualification are unchanged.
Both public (`say`) and team (`sayteam`) log messages are captured. Team messages
carry a **Team chat** label in chat and saved excerpts. Collection runs without a live
viewer and checks surviving logs once after startup, including idle servers.
Archive coverage still depends on the source log and accepted match telemetry:
logs that were deleted or rotated before collection, or old clock segments that
cannot safely be associated with a match, cannot be reconstructed. The collector
retains a bounded log tail (up to 2,000 lines / 256 KiB); it is not a full historical
log import. Already saved case excerpts survive later source cleanup.
Case decisions, edits, and restrictions check the current revision; refresh before Case decisions, edits, and restrictions check the current revision; refresh before
retrying a decision if another staff member has changed the case. Evidence, retrying a decision if another staff member has changed the case. Evidence,
restrictions and append-only moderation events survive source-match cleanup and restrictions and append-only moderation events survive source-match cleanup and
+15 -7
View File
@@ -4,6 +4,8 @@ export interface ChatMessage {
slot: number; slot: number;
player: string; player: string;
message: string; message: string;
/** Omitted for public chat to preserve existing archived message identities. */
channel?: 'team';
} }
export type ChatEntry = export type ChatEntry =
@@ -17,7 +19,12 @@ export interface ChatContext {
round: number | null; round: number | null;
} }
export type ContextualChatEntry = ChatEntry & { context: ChatContext; matchId?: number }; export type ContextualChatEntry = ChatEntry & {
context: ChatContext;
matchId?: number;
/** Stable archive identity, independent of estimated wall-clock timestamps. */
messageId?: string;
};
export interface ChatMatch { export interface ChatMatch {
id?: number; id?: number;
@@ -93,18 +100,19 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
previousSeconds = seconds; previousSeconds = seconds;
} }
const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line); const match = /^\s*(\d+:[0-5]\d)\s+(say|sayteam);([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
if (!match) continue; if (!match) continue;
const message = plainText(match[5]); const message = plainText(match[6]);
if (!message) continue; if (!message) continue;
push({ push({
type: 'message', type: 'message',
message: { message: {
time: match[1], time: match[1],
guid: match[2], guid: match[3],
slot: Number(match[3]), slot: Number(match[4]),
player: plainText(match[4]) || 'Unknown player', player: plainText(match[5]) || 'Unknown player',
message message,
...(match[2] === 'sayteam' ? { channel: 'team' as const } : {})
} }
}); });
} }
+8 -3
View File
@@ -10,6 +10,7 @@ export interface MatchHistoryQuery {
sort: MatchSort; sort: MatchSort;
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
page: number; page: number;
archive: boolean;
} }
export const matchQueryKeys = [ export const matchQueryKeys = [
'game', 'game',
@@ -19,7 +20,8 @@ export const matchQueryKeys = [
'status', 'status',
'sort', 'sort',
'direction', 'direction',
'page' 'page',
'archive'
] as const; ] as const;
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery { export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const game = params.get('game') ?? ''; const game = params.get('game') ?? '';
@@ -30,6 +32,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const sort = params.get('sort') || 'started'; const sort = params.get('sort') || 'started';
const direction = params.get('direction') || 'desc'; const direction = params.get('direction') || 'desc';
const page = Number(params.get('page') || 1); const page = Number(params.get('page') || 1);
const archive = params.get('archive') ?? '';
if ( if (
!['', 't5', 't6'].includes(game) || !['', 't5', 't6'].includes(game) ||
map.length > 1024 || map.length > 1024 ||
@@ -40,7 +43,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
!['asc', 'desc'].includes(direction) || !['asc', 'desc'].includes(direction) ||
!Number.isSafeInteger(page) || !Number.isSafeInteger(page) ||
page < 1 || page < 1 ||
page > 1000000 page > 1000000 ||
!['', '0', '1'].includes(archive)
) )
throw new Error('Invalid match history filters.'); throw new Error('Invalid match history filters.');
return { return {
@@ -51,7 +55,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
status: status as MatchHistoryQuery['status'], status: status as MatchHistoryQuery['status'],
sort: sort as MatchSort, sort: sort as MatchSort,
direction: direction as 'asc' | 'desc', direction: direction as 'asc' | 'desc',
page page,
archive: archive === '1'
}; };
} }
/** Namespaced filters also work inside an instance's Matches tab. */ /** Namespaced filters also work inside an instance's Matches tab. */
-1
View File
@@ -47,7 +47,6 @@ export interface MatchPlayer {
} }
export interface MatchDetail extends ServerMatch { export interface MatchDetail extends ServerMatch {
status: 'completed';
game: string; game: string;
startObserved: boolean; startObserved: boolean;
players: MatchPlayer[]; players: MatchPlayer[];
+98
View File
@@ -24,6 +24,13 @@ export const moderationCaseStatuses = [
] as const; ] as const;
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number]; export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent'; export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
export type ModerationCaseRelation = (typeof moderationCaseRelations)[number];
export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string> = {
same_incident: 'Same incident',
appeal_of: 'Appeal of',
duplicate_of: 'Duplicate of'
};
export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = { export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = {
open: 'Open', open: 'Open',
in_review: 'In review', in_review: 'In review',
@@ -41,6 +48,49 @@ export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
return status === 'open' || status === 'in_review' || status === 'escalated'; return status === 'open' || status === 'in_review' || status === 'escalated';
} }
export const moderationConcernCategories = [
'hate_speech',
'harassment',
'threats',
'safeguarding',
'spam',
'cheating',
'other'
] as const;
export type ModerationConcernCategory = (typeof moderationConcernCategories)[number];
export const moderationConcernLabels: Record<ModerationConcernCategory, string> = {
hate_speech: 'Hate speech',
harassment: 'Harassment',
threats: 'Threats',
safeguarding: 'Safeguarding',
spam: 'Spam',
cheating: 'Cheating',
other: 'Other'
};
export const moderationFindingOutcomes = [
'substantiated',
'inconclusive',
'unsubstantiated'
] as const;
export type ModerationFindingOutcome = (typeof moderationFindingOutcomes)[number];
export const moderationFindingLabels: Record<ModerationFindingOutcome, string> = {
substantiated: 'Substantiated',
inconclusive: 'Inconclusive',
unsubstantiated: 'Unsubstantiated'
};
export interface ModerationCaseAssessment {
revision: number;
summary: string;
rules: string[];
findings: {
category: ModerationConcernCategory;
finding: ModerationFindingOutcome;
detail: string;
}[];
actor: string;
createdAt: string;
}
export interface ModerationPlayer { export interface ModerationPlayer {
game: ModerationGame; game: ModerationGame;
guid: string; guid: string;
@@ -112,6 +162,11 @@ export interface ModerationCase {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
revision: number; revision: number;
assessment?: ModerationCaseAssessment | null;
reviewAt?: string | null;
reviewReason?: string;
reviewSanctionId?: string | null;
unreadUpdates?: number;
evidence: { evidence: {
id: string; id: string;
text: string; text: string;
@@ -125,11 +180,51 @@ export interface ModerationCaseDetail {
case: ModerationCase; case: ModerationCase;
player: ModerationPlayer; player: ModerationPlayer;
sanctions: ModerationSanction[]; sanctions: ModerationSanction[];
linkedActions?: ModerationCaseAction[];
availableActions?: ModerationCaseAction[];
assessment?: ModerationCaseAssessment | null;
assessmentHistory?: ModerationCaseAssessment[];
relatedCases?: ModerationCaseRelationship[];
candidateCases?: ModerationCaseCandidate[];
notifications?: ModerationCaseNotification[];
events: ModerationEvent[]; events: ModerationEvent[];
coverage: ModerationCoverage[]; coverage: ModerationCoverage[];
assignees: { id: string; username: string }[]; assignees: { id: string; username: string }[];
checkedAt: string; checkedAt: string;
} }
export type ModerationCaseReference = Pick<
ModerationCase,
'id' | 'title' | 'status' | 'game' | 'guid'
>;
export interface ModerationCaseRelationship {
id: string;
relation: ModerationCaseRelation;
direction: 'outgoing' | 'incoming';
case: ModerationCaseReference;
actor: string;
createdAt: string;
}
export interface ModerationCaseCandidate extends ModerationCaseReference {
sharedMatch: boolean;
}
export interface ModerationCaseNotification {
id: number;
caseId: string;
caseTitle: string;
kind: 'evidence' | 'assignment';
createdAt: string;
readAt: string | null;
}
export interface ModerationCaseAction {
id: string;
type: 'restriction' | 'action';
kind: string;
reason: string;
actor: string;
createdAt: string;
state: string;
expiresAt: string | null;
}
export interface ModerationEvent { export interface ModerationEvent {
id: number; id: number;
game: ModerationGame | null; game: ModerationGame | null;
@@ -158,6 +253,7 @@ export interface ModerationQuery {
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus; caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
priority?: '' | ModerationCasePriority; priority?: '' | ModerationCasePriority;
assignedTo?: string; assignedTo?: string;
review?: '' | 'due' | 'scheduled' | 'updates';
} }
export interface ModerationOverview { export interface ModerationOverview {
query: ModerationQuery; query: ModerationQuery;
@@ -170,6 +266,8 @@ export interface ModerationOverview {
total: number; total: number;
limit: number; limit: number;
checkedAt: string; checkedAt: string;
notifications?: ModerationCaseNotification[];
unreadCount?: number;
} }
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */ /** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
+35 -2
View File
@@ -28,7 +28,15 @@ import { migrateBank } from './bank.js';
import { migrateAfk } from './afk.js'; import { migrateAfk } from './afk.js';
import { migrateBankCredentials } from './bank-credentials.js'; import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js'; import { migrateTemperatures } from '../hosts/temperatures.js';
import { migrateModeration, migrateModerationCases } from '../moderation/schema.js'; import {
migrateModeration,
migrateModerationCases,
migrateModerationCaseActions,
migrateModerationCaseAssessments,
migrateModerationCaseRelationships,
migrateModerationCaseFollowups,
migrateModerationCaseReferenceIndexes
} from '../moderation/schema.js';
import type { Migration } from './migration-runner.js'; import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an // Append only: never renumber entries or change an applied migration to add an
@@ -170,7 +178,32 @@ export const migrations: readonly Migration[] = [
mode: 'foreign-keys-off', mode: 'foreign-keys-off',
run: migrateModerationCases run: migrateModerationCases
}, },
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk } { version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk },
{
version: 31,
name: 'moderation case links to existing actions',
run: migrateModerationCaseActions
},
{
version: 32,
name: 'moderation case assessment revision history',
run: migrateModerationCaseAssessments
},
{
version: 33,
name: 'moderation case relationships and duplicate review',
run: migrateModerationCaseRelationships
},
{
version: 34,
name: 'moderation follow-up dates and personal updates',
run: migrateModerationCaseFollowups
},
{
version: 35,
name: 'moderation case foreign key reference indexes',
run: migrateModerationCaseReferenceIndexes
}
]; ];
export const databaseVersion = migrations.length; export const databaseVersion = migrations.length;
+59 -9
View File
@@ -123,9 +123,15 @@ export function serverChatTimeline(server: ServerRow, tail: TailResult): Context
export function matchChatTimeline(matchId: number): ContextualChatEntry[] { export function matchChatTimeline(matchId: number): ContextualChatEntry[] {
return ( return (
db() db()
.prepare('SELECT entry FROM match_chat WHERE match_id = ? ORDER BY rowid') .prepare(
.all(matchId) as { entry: string }[] 'SELECT message_key,occurrence,entry FROM match_chat WHERE match_id = ? ORDER BY rowid'
).map((row) => JSON.parse(row.entry)); )
.all(matchId) as { message_key: string; occurrence: number; entry: string }[]
).map((row) => ({
...JSON.parse(row.entry),
matchId,
messageId: `${matchId}:${row.message_key}:${row.occurrence}`
}));
} }
/** Save before filtering the live view so completed matches retain their messages. */ /** Save before filtering the live view so completed matches retain their messages. */
@@ -206,10 +212,14 @@ interface ChatReadState {
entries: ContextualChatEntry[]; entries: ContextualChatEntry[];
contextVersion?: string; contextVersion?: string;
wasOccupied: boolean; wasOccupied: boolean;
needsDrain: boolean;
bytesRead: number;
} }
const readers = new Map<string, ChatReadState>(); const readers = new Map<string, ChatReadState>();
const failures = new Map<string, number>(); const failures = new Map<string, number>();
const CHAT_READ_MIN_AGE_MS = 15_000; const CHAT_READ_MIN_AGE_MS = 15_000;
const MAX_IDLE_STALLED_READS = 3;
const idleStalls = new Map<string, number>();
type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown }); type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
const lastReads = new Map<string, LastChatRead>(); const lastReads = new Map<string, LastChatRead>();
@@ -243,6 +253,7 @@ export function collectServerChat(
const key = chatReadKey(server); const key = chatReadKey(server);
const existing = pending.get(key); const existing = pending.get(key);
if (existing) return existing; if (existing) return existing;
let progressed = false;
const work = (async () => { const work = (async () => {
const path = resolveLogPath(server.name, gameLogKey(server.name)); const path = resolveLogPath(server.name, gameLogKey(server.name));
if (!path) throw new Error('Invalid server log path'); if (!path) throw new Error('Invalid server log path');
@@ -257,7 +268,9 @@ export function collectServerChat(
partial: Buffer.alloc(0), partial: Buffer.alloc(0),
skipLine: false, skipLine: false,
entries: [], entries: [],
wasOccupied: false wasOccupied: false,
needsDrain: false,
bytesRead: 0
}; };
const tail = await readLogChunk(path, state.cursor); const tail = await readLogChunk(path, state.cursor);
const current = getServer(server.name); const current = getServer(server.name);
@@ -284,7 +297,10 @@ export function collectServerChat(
state.entries = []; state.entries = [];
} }
state.cursor = tail.cursor; state.cursor = tail.cursor;
const bytes = Buffer.concat([state.partial, Buffer.from(tail.contentBase64, 'base64')]); const content = Buffer.from(tail.contentBase64, 'base64');
progressed = content.length > 0;
state.bytesRead += content.length;
const bytes = Buffer.concat([state.partial, content]);
const end = bytes.lastIndexOf(10) + 1; const end = bytes.lastIndexOf(10) + 1;
if (end) { if (end) {
const lines = bytes const lines = bytes
@@ -312,14 +328,17 @@ export function collectServerChat(
state.skipLine = true; state.skipLine = true;
} }
// File mtime anchors the final line only after the backlog has been read. // File mtime anchors the final line only after the backlog has been read.
if (tail.cursor.offset < tail.size || state.partial.length) { state.needsDrain = tail.cursor.offset < tail.size || state.partial.length > 0;
if (state.needsDrain) {
state.signature = undefined; state.signature = undefined;
readers.set(server.name, state); readers.set(server.name, state);
return { entries: currentChatEntries(current, state.entries), missing: false }; return { entries: currentChatEntries(current, state.entries), missing: false };
} }
} else { } else {
// Older agents remain readable during a rolling update. // Older agents remain readable during a rolling update.
state.needsDrain = false;
changed = state.signature !== `${tail.size}:${tail.mtime}`; changed = state.signature !== `${tail.size}:${tail.mtime}`;
progressed = changed;
if (changed) state.lines = tail.lines; if (changed) state.lines = tail.lines;
} }
const signature = `${tail.size}:${tail.mtime}`; const signature = `${tail.size}:${tail.mtime}`;
@@ -348,6 +367,9 @@ export function collectServerChat(
})() })()
.then( .then(
(result) => { (result) => {
const state = readers.get(server.name);
if (progressed || result.missing || (state?.key === key && !state.needsDrain))
idleStalls.delete(key);
lastReads.set(key, { completedAt: Date.now(), missing: result.missing }); lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
return result; return result;
}, },
@@ -371,25 +393,53 @@ export async function collectFleetChat(): Promise<void> {
for (const name of readers.keys()) if (!names.has(name)) readers.delete(name); for (const name of readers.keys()) if (!names.has(name)) readers.delete(name);
for (const name of failures.keys()) if (!names.has(name)) failures.delete(name); for (const name of failures.keys()) if (!names.has(name)) failures.delete(name);
for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key); for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key);
for (const key of idleStalls.keys()) if (!keys.has(key)) idleStalls.delete(key);
const servers = live.filter((server) => { const servers = live.filter((server) => {
if (server.host_id && !hosts.get(server.host_id)?.connected) return false; if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
if (server.container_state && !['running', 'unknown'].includes(server.container_state)) if (server.container_state && !['running', 'unknown'].includes(server.container_state))
return false; return false;
const players = observe(server).last_players ?? server.last_players; const players = observe(server).last_players ?? server.last_players;
// One final read after players leave retains messages written at match end. const key = chatReadKey(server);
return players !== 0 || readers.get(server.name)?.wasOccupied; if (players !== 0) {
idleStalls.delete(key);
return true;
}
const stalls = idleStalls.get(key) ?? 0;
if (stalls >= MAX_IDLE_STALLED_READS) return false;
const last = lastReads.get(key);
if (stalls > 0 && last && Date.now() - last.completedAt < CHAT_READ_MIN_AGE_MS)
return false;
const state = readers.get(server.name);
// Pick up surviving final logs after a collector restart, then finish any
// unread chunks even after players leave. Errors and incomplete final lines
// have bounded idle retries rather than polling an empty server forever.
return (
!last ||
'error' in last ||
(state?.key === key && (state.wasOccupied || state.needsDrain))
);
}); });
// Bound agent traffic and isolate failures to their own server. // Bound agent traffic and isolate failures to their own server.
async function collect() { async function collect() {
for (let server = servers.shift(); server; server = servers.shift()) { for (let server = servers.shift(); server; server = servers.shift()) {
const key = chatReadKey(server);
const before = readers.get(server.name);
try { try {
await collectServerChat(server); await collectServerChat(server);
const state = readers.get(server.name); const state = readers.get(server.name);
if (state) if (state?.key === key) {
state.wasOccupied = state.wasOccupied =
(observe(server).last_players ?? server.last_players ?? 0) > 0; (observe(server).last_players ?? server.last_players ?? 0) > 0;
if (!state.wasOccupied && state.needsDrain) {
const progressed =
before?.key !== key || state.bytesRead > before.bytesRead;
idleStalls.set(key, progressed ? 0 : (idleStalls.get(key) ?? 0) + 1);
} else idleStalls.delete(key);
}
failures.delete(server.name); failures.delete(server.name);
} catch (error) { } catch (error) {
if ((observe(server).last_players ?? server.last_players) === 0)
idleStalls.set(key, (idleStalls.get(key) ?? 0) + 1);
if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) { if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) {
console.error(`[chat] Could not collect chat for ${server.name}`, error); console.error(`[chat] Could not collect chat for ${server.name}`, error);
failures.set(server.name, Date.now()); failures.set(server.name, Date.now());
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import type { AuthUser } from '../../auth.js';
import { AuthError } from '../auth/index.js';
import { moderationActor } from '../moderation/core.js';
export type MatchArchiveLocals = { user: AuthUser | null };
/** Recheck current account privileges before returning the expanded moderation archive. */
export function requireMatchArchiveAccess(user: Pick<AuthUser, 'id'> | null | undefined): void {
try {
moderationActor(user?.id ?? '');
} catch (cause) {
if (cause instanceof AuthError) error(cause.status, cause.message);
throw cause;
}
}
+9 -5
View File
@@ -32,7 +32,7 @@ export function serverMatchHistory(
now = Date.now() now = Date.now()
): MatchHistoryResponse { ): MatchHistoryResponse {
const handle = db(); const handle = db();
const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`]; const clauses = [query.archive ? '1=1' : `m.highest_round >= ${MIN_RECORDED_ROUND}`];
const parameters: Array<string | number> = []; const parameters: Array<string | number> = [];
if (server !== undefined) { if (server !== undefined) {
clauses.push('m.server = ?'); clauses.push('m.server = ?');
@@ -93,14 +93,18 @@ export function serverMatchHistory(
} }
/** Read durable match records without depending on a current server or scoreboard. */ /** Read durable match records without depending on a current server or scoreboard. */
export function getMatchDetail(id: number): MatchDetail | null { export function getMatchDetail(id: number, archive = false): MatchDetail | null {
if (!Number.isSafeInteger(id) || id < 1) return null; if (!Number.isSafeInteger(id) || id < 1) return null;
const handle = db(); const handle = db();
const match = handle const match = handle
.prepare( .prepare(
`SELECT ${matchSummary}, m.start_observed AS startObserved `SELECT ${matchSummary}, m.start_observed AS startObserved
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed' FROM leaderboard_matches m WHERE m.id = ?${
archive
? ''
: ` AND m.status = 'completed'
AND m.highest_round >= ${MIN_RECORDED_ROUND}` AND m.highest_round >= ${MIN_RECORDED_ROUND}`
}`
) )
.get(id) as .get(id) as
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number }) | (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number })
@@ -116,7 +120,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
(SELECT identity.name FROM leaderboard_players identity (SELECT identity.name FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name, WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
p.has_stats AS hasStats, p.has_stats AS hasStats,
CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd, CASE WHEN ? != 'completed' THEN 0 WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt, p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound, CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived, CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
@@ -131,7 +135,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
FROM leaderboard_match_players p WHERE p.match_id = ? FROM leaderboard_match_players p WHERE p.match_id = ?
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid` ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
) )
.all(match.game, match.reporterMatchKey, id) as Array< .all(match.game, match.status, match.reporterMatchKey, id) as Array<
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & { Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
hasStats: number; hasStats: number;
presentAtEnd: number; presentAtEnd: number;
+151 -40
View File
@@ -1,5 +1,10 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { archivedEvidenceSnapshots } from './evidence.js';
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
import { assessmentDraft } from './assessments.js';
import { linkCases, unlinkCases } from './relationships.js';
import { notifyCase, caseReviewSchedule, readCaseUpdates } from './followups.js';
import { AuthError } from '../auth/index.js'; import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import { import {
@@ -7,7 +12,6 @@ import {
isModerationCaseOpen, isModerationCaseOpen,
moderationCaseOutcomes, moderationCaseOutcomes,
moderationCaseStatusLabels, moderationCaseStatusLabels,
moderationGuid,
type ModerationCaseStatus, type ModerationCaseStatus,
type ModerationCasePriority, type ModerationCasePriority,
type ModerationGame, type ModerationGame,
@@ -50,6 +54,9 @@ type CaseRow = {
title: string; title: string;
priority: ModerationCasePriority; priority: ModerationCasePriority;
assignee_id: string | null; assignee_id: string | null;
review_at: string | null;
review_reason: string;
review_sanction_id: string | null;
}; };
function serverTarget(name: unknown, game?: ModerationGame) { function serverTarget(name: unknown, game?: ModerationGame) {
@@ -220,7 +227,13 @@ function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: st
.run(now, id); .run(now, id);
advancePolicy(); advancePolicy();
moderationEvent(actor, 'restriction.revoked', row, id, reason, now); moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
if (row.case_id) touchCase(row.case_id, now); db()
.prepare(
`UPDATE moderation_cases SET updated_at=?,revision=revision+1
WHERE id=? OR id IN (SELECT case_id FROM moderation_case_action_links
WHERE action_type='restriction' AND action_id=?)`
)
.run(now, row.case_id, id);
return { id }; return { id };
} }
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) { function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
@@ -381,7 +394,8 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
assignee.id 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) archivedEvidence(actor, id, target, input.evidence, now); const attached =
input.evidence === undefined ? 0 : archivedEvidence(actor, id, target, input.evidence, now);
moderationEvent(actor, 'case.opened', target, id, title, now); moderationEvent(actor, 'case.opened', target, id, title, now);
if (priority !== 'normal' || assignee.id) if (priority !== 'normal' || assignee.id)
moderationEvent( moderationEvent(
@@ -395,6 +409,8 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
}), }),
now now
); );
if (note || attached) notifyCase(actor.id, id, 'evidence', now);
if (assignee.id) notifyCase(actor.id, id, 'assignment', now);
return { id }; return { id };
} }
function archivedEvidence( function archivedEvidence(
@@ -404,42 +420,10 @@ function archivedEvidence(
value: unknown, value: unknown,
now: string now: string
): number { ): number {
const requested = value as { matchId: unknown; message: unknown }; const snapshots = archivedEvidenceSnapshots(id, target, value);
if (!requested || !Number.isSafeInteger(requested.matchId)) for (const snapshot of snapshots)
throw new AuthError('Choose a recorded chat message.'); evidence(actor, id, snapshot.text, snapshot.source, snapshot.snapshot, now);
textField(requested.message, 'Chat message', 4096); return snapshots.length;
// Evidence must match the archived bytes, including intentional whitespace.
const message = requested.message as string;
const match = db()
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
.get(requested.matchId) as { game: string; server: string; started_at: string } | undefined;
if (!match || match.game !== target.game)
throw new AuthError('The source match is unavailable.', 404);
const entries = db()
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
.all(requested.matchId) as { entry: string }[];
const found = entries
.filter((row) => {
const entry = JSON.parse(row.entry);
return (
entry.type === 'message' &&
moderationGuid(target.game, entry.message.guid) === target.guid &&
entry.message.message === message
);
})
.slice(0, 10);
if (!found.length)
throw new AuthError('The original chat message could not be verified in the archive.', 409);
for (const row of found)
evidence(
actor,
id,
message,
`Archived chat · ${match.server} · match ${requested.matchId}`,
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
now
);
return found.length;
} }
function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string) { function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string) {
const row = caseRow(input.id); const row = caseRow(input.id);
@@ -488,6 +472,8 @@ function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string
JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }), JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }),
now now
); );
if (assigneeId !== row.assignee_id)
notifyCase(actor.id, row.id, 'assignment', now, row.assignee_id);
return { id: row.id }; return { id: row.id };
} }
export function mutateModeration(actorId: string, input: Record<string, unknown>) { export function mutateModeration(actorId: string, input: Record<string, unknown>) {
@@ -497,6 +483,98 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
if (input.op === 'action') return liveAction(actor, input, now); if (input.op === 'action') return liveAction(actor, input, now);
if (input.op === 'create-case') return createCase(actor, input, now); if (input.op === 'create-case') return createCase(actor, input, now);
if (input.op === 'update-case') return updateCase(actor, input, now); if (input.op === 'update-case') return updateCase(actor, input, now);
if (input.op === 'schedule-case-review') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const schedule = caseReviewSchedule(row, input, now);
if (
row.review_at === schedule.reviewAt &&
row.review_reason === schedule.reason &&
row.review_sanction_id === schedule.restrictionId
)
return { id: row.id };
db()
.prepare(
`UPDATE moderation_cases SET review_at=?,review_reason=?,review_sanction_id=?,
updated_at=?,revision=revision+1 WHERE id=?`
)
.run(schedule.reviewAt, schedule.reason, schedule.restrictionId, now, row.id);
moderationEvent(
actor,
'case.review_scheduled',
row,
row.id,
JSON.stringify(schedule),
now
);
return { id: row.id };
}
if (input.op === 'read-case-updates') {
const row = caseRow(input.id);
readCaseUpdates(actor.id, row.id, input.throughId, now);
return { id: row.id };
}
if (input.op === 'link-case' || input.op === 'unlink-case') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
if (input.op === 'link-case')
linkCases(
actor,
row,
caseRow(input.relatedCaseId),
input.relation,
input.reason,
now
);
else unlinkCases(actor, row, input.relationshipId, input.reason, now);
return { id: row.id };
}
if (input.op === 'assess-case') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const draft = assessmentDraft(input);
const data = JSON.stringify(draft);
const prior = db()
.prepare(
'SELECT data FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1'
)
.get(row.id) as { data: string } | undefined;
if (prior?.data === data) throw new AuthError('Change the assessment before saving.');
db()
.prepare('INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)')
.run(row.id, row.revision + 1, data, actor.id, actor.username, now);
touchCase(row.id, now);
moderationEvent(
actor,
'case.assessed',
row,
row.id,
JSON.stringify({ assessmentRevision: row.revision + 1, summary: draft.summary }),
now
);
return { id: row.id };
}
if (input.op === 'link-case-action') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const reference = caseActionReference({ type: input.actionType, id: input.actionId });
const reason = textField(input.reason, 'Link reason', 4000);
recordedCaseAction(row, reference, now);
if (isCaseActionLinked(row.id, reference)) return { id: row.id };
db()
.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)')
.run(row.id, reference.type, reference.id, actor.username, now);
touchCase(row.id, now);
moderationEvent(
actor,
'case.action_linked',
row,
row.id,
JSON.stringify({ actionReference: reference, reason }),
now
);
return { id: row.id };
}
if (input.op === 'case-note') { if (input.op === 'case-note') {
const row = caseRow(input.id); const row = caseRow(input.id);
if (input.revision !== undefined) checkCaseRevision(row, input.revision); if (input.revision !== undefined) checkCaseRevision(row, input.revision);
@@ -504,6 +582,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
evidence(actor, row.id, text, 'Staff note', null, now); evidence(actor, row.id, text, 'Staff note', null, now);
touchCase(row.id, now); touchCase(row.id, now);
moderationEvent(actor, 'case.note', row, row.id, text, now); moderationEvent(actor, 'case.note', row, row.id, text, now);
notifyCase(actor.id, row.id, 'evidence', now);
return { id: row.id }; return { id: row.id };
} }
if (input.op === 'resolve-case') { if (input.op === 'resolve-case') {
@@ -513,13 +592,43 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
const outcome = moderationCaseOutcomes.find((status) => status === input.status); const outcome = moderationCaseOutcomes.find((status) => status === input.status);
if (!outcome) throw new AuthError('Choose a case outcome.'); if (!outcome) throw new AuthError('Choose a case outcome.');
const reason = textField(input.reason, 'Decision reason', 4000); const reason = textField(input.reason, 'Decision reason', 4000);
const reference =
input.actionReference === undefined || input.actionReference === null
? null
: caseActionReference(input.actionReference);
if (reference) {
const action = recordedCaseAction(row, reference, now);
if (!isCaseActionLinked(row.id, reference))
throw new AuthError(
'Link the recorded action to this case before citing it.',
409
);
if (
outcome === 'warning_issued' &&
(action.type !== 'action' ||
action.kind !== 'warn' ||
action.state !== 'confirmed')
)
throw new AuthError('Warning issued must reference a confirmed warning.');
if (outcome === 'restriction_applied' && action.type !== 'restriction')
throw new AuthError(
'Restriction applied must reference a recorded restriction.'
);
}
evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true); evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
db() db()
.prepare( .prepare(
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?' 'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
) )
.run(outcome, now, row.id); .run(outcome, now, row.id);
moderationEvent(actor, `case.${outcome}`, row, row.id, reason, now); moderationEvent(
actor,
`case.${outcome}`,
row,
row.id,
reference ? JSON.stringify({ reason, actionReference: reference }) : reason,
now
);
return { id: row.id }; return { id: row.id };
} }
if (input.op === 'reopen-case') { if (input.op === 'reopen-case') {
@@ -548,6 +657,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
const row = caseRow(input.id); const row = caseRow(input.id);
checkCaseRevision(row, input.revision); checkCaseRevision(row, input.revision);
const count = archivedEvidence(actor, row.id, row, input.evidence, now); const count = archivedEvidence(actor, row.id, row, input.evidence, now);
if (!count) return { id: row.id };
touchCase(row.id, now); touchCase(row.id, now);
moderationEvent( moderationEvent(
actor, actor,
@@ -557,6 +667,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }), JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
now now
); );
notifyCase(actor.id, row.id, 'evidence', now);
return { id: row.id }; return { id: row.id };
} }
if (input.op === 'note') { if (input.op === 'note') {
+63
View File
@@ -0,0 +1,63 @@
import {
moderationConcernCategories,
moderationFindingOutcomes,
type ModerationCaseAssessment
} from '../../moderation.js';
import { AuthError } from '../auth/index.js';
import { db } from '../db/index.js';
import { textField } from './core.js';
type AssessmentDraft = Pick<ModerationCaseAssessment, 'summary' | 'rules' | 'findings'>;
export function assessmentDraft(input: Record<string, unknown>): AssessmentDraft {
const summary = textField(input.summary, 'Assessment summary', 2000);
if (!Array.isArray(input.rules) || input.rules.length > 20)
throw new AuthError('Choose up to 20 applicable rules.');
const rules = input.rules.map((rule) => textField(rule, 'Applicable rule', 160));
if (
!Array.isArray(input.findings) ||
!input.findings.length ||
input.findings.length > moderationConcernCategories.length
)
throw new AuthError('Record between 1 and 7 findings, one per concern category.');
const categories = new Set<string>();
const findings = input.findings.map((value): AssessmentDraft['findings'][number] => {
if (!value || typeof value !== 'object')
throw new AuthError('Choose a concern category and finding.');
const category = moderationConcernCategories.find(
(category) => category === value.category
);
const finding = moderationFindingOutcomes.find((finding) => finding === value.finding);
if (!category || !finding)
throw new AuthError('Choose a known concern category and finding.');
if (categories.has(category))
throw new AuthError('Record each concern category only once.');
categories.add(category);
return { category, finding, detail: textField(value.detail, 'Finding detail', 1000) };
});
return { summary, rules, findings };
}
export function caseAssessmentHistory(caseId: string): ModerationCaseAssessment[] {
return (
db()
.prepare(
`SELECT revision,data,actor,created_at AS createdAt
FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC`
)
.all(caseId) as { revision: number; data: string; actor: string; createdAt: string }[]
).map(({ data, ...revision }) => ({ ...JSON.parse(data), ...revision }));
}
export function currentCaseAssessment(caseId: string): ModerationCaseAssessment | null {
const row = db()
.prepare(
`SELECT revision,data,actor,created_at AS createdAt
FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1`
)
.get(caseId) as
{ revision: number; data: string; actor: string; createdAt: string } | undefined;
if (!row) return null;
const { data, ...revision } = row;
return { ...JSON.parse(data), ...revision };
}
+92
View File
@@ -0,0 +1,92 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { textField } from './core.js';
import type { ModerationCaseAction, ModerationGame } from '../../moderation.js';
type Subject = { game: ModerationGame; guid: string };
export type CaseActionReference = Pick<ModerationCaseAction, 'type' | 'id'>;
export function caseActionReference(value: unknown): CaseActionReference {
if (!value || typeof value !== 'object') throw new AuthError('Choose a recorded action.');
const reference = value as Record<string, unknown>;
if (reference.type !== 'action' && reference.type !== 'restriction')
throw new AuthError('Choose a recorded warning, action, or restriction.');
return { type: reference.type, id: textField(reference.id, 'Action ID', 128) };
}
const restrictionColumns = `id,'restriction' AS type,kind,reason,actor,
created_at AS createdAt,expires_at AS expiresAt,
CASE WHEN revoked_at IS NOT NULL THEN 'revoked'
WHEN expires_at IS NOT NULL AND expires_at<=? THEN 'expired' ELSE 'active' END AS state`;
const actionColumns = `id,'action' AS type,kind,reason,actor,
created_at AS createdAt,expires_at AS expiresAt,state`;
/** Only actual records for this account can become case evidence. */
export function recordedCaseAction(
subject: Subject,
reference: CaseActionReference,
now: string
): ModerationCaseAction {
const handle = db();
const action =
reference.type === 'restriction'
? handle
.prepare(
`SELECT ${restrictionColumns} FROM moderation_sanctions
WHERE id=? AND guid=? AND (game=? OR scope='fleet')`
)
.get(now, reference.id, subject.guid, subject.game)
: handle
.prepare(
`SELECT ${actionColumns} FROM moderation_actions
WHERE id=? AND guid=? AND game=?`
)
.get(reference.id, subject.guid, subject.game);
if (!action)
throw new AuthError('The recorded action is unavailable for this case’s player.', 404);
return action as ModerationCaseAction;
}
export function isCaseActionLinked(caseId: string, reference: CaseActionReference): boolean {
return Boolean(
db()
.prepare(
`SELECT 1 FROM moderation_case_action_links
WHERE case_id=? AND action_type=? AND action_id=?
UNION ALL SELECT 1 FROM moderation_sanctions
WHERE ?='restriction' AND id=? AND case_id=? LIMIT 1`
)
.get(caseId, reference.type, reference.id, reference.type, reference.id, caseId)
);
}
export function caseActionLists(caseId: string, subject: Subject, now: string) {
const handle = db();
const records = handle
.prepare(
`SELECT ${restrictionColumns},
(case_id=? OR EXISTS (SELECT 1 FROM moderation_case_action_links l
WHERE l.case_id=? AND l.action_type='restriction' AND l.action_id=moderation_sanctions.id)) AS linked
FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet')
UNION ALL SELECT ${actionColumns},
EXISTS (SELECT 1 FROM moderation_case_action_links l
WHERE l.case_id=? AND l.action_type='action' AND l.action_id=moderation_actions.id) AS linked
FROM moderation_actions WHERE guid=? AND game=?
ORDER BY createdAt DESC,id`
)
.all(
now,
caseId,
caseId,
subject.guid,
subject.game,
caseId,
subject.guid,
subject.game
) as Array<ModerationCaseAction & { linked: number | null }>;
const linkedActions: ModerationCaseAction[] = [];
const availableActions: ModerationCaseAction[] = [];
for (const { linked, ...record } of records)
(linked ? linkedActions : availableActions).push(record);
return { linkedActions, availableActions };
}
+21 -6
View File
@@ -2,6 +2,10 @@ import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js'; import { AuthError } from '../auth/index.js';
import { moderationActor, textField } from './core.js'; import { moderationActor, textField } from './core.js';
import { moderationOverview } from './overview.js'; import { moderationOverview } from './overview.js';
import { caseActionLists } from './case-actions.js';
import { caseAssessmentHistory } from './assessments.js';
import { relatedCases, candidateCases } from './relationships.js';
import { caseNotifications, unreadCaseUpdates } from './followups.js';
import type { import type {
ModerationCase, ModerationCase,
ModerationCaseDetail, ModerationCaseDetail,
@@ -19,7 +23,8 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
.prepare( .prepare(
`SELECT c.id,c.game,c.guid,c.title,c.status,c.priority, `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.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 c.updated_at AS updatedAt,c.revision,c.review_at AS reviewAt,
c.review_reason AS reviewReason,c.review_sanction_id AS reviewSanctionId FROM moderation_cases c
LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?` LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?`
) )
.get(caseId) as Omit<ModerationCase, 'evidence'> | undefined; .get(caseId) as Omit<ModerationCase, 'evidence'> | undefined;
@@ -42,9 +47,11 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
.prepare( .prepare(
`SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note, `SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note,
created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision
FROM moderation_sanctions WHERE case_id=? ORDER BY created_at DESC,id` FROM moderation_sanctions WHERE case_id=? OR id IN
(SELECT action_id FROM moderation_case_action_links WHERE case_id=? AND action_type='restriction')
ORDER BY created_at DESC,id`
) )
.all(caseId) as Omit<ModerationSanction, 'active'>[] .all(caseId, caseId) as Omit<ModerationSanction, 'active'>[]
).map((restriction) => ({ ).map((restriction) => ({
...restriction, ...restriction,
active: active:
@@ -55,17 +62,19 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
.prepare( .prepare(
`SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt `SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt
FROM moderation_events WHERE subject_id=? OR subject_id IN FROM moderation_events WHERE subject_id=? OR subject_id IN
(SELECT id FROM moderation_sanctions WHERE case_id=?) ORDER BY id DESC` (SELECT id FROM moderation_sanctions WHERE case_id=?) OR subject_id IN
(SELECT action_id FROM moderation_case_action_links WHERE case_id=?) ORDER BY id DESC`
) )
.all(caseId, caseId) as ModerationEvent[]; .all(caseId, caseId, caseId) as ModerationEvent[];
const assignees = handle const assignees = handle
.prepare( .prepare(
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator') `SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
AND disabled=0 AND must_change_password=0 ORDER BY username COLLATE NOCASE,id` AND disabled=0 AND must_change_password=0 ORDER BY username COLLATE NOCASE,id`
) )
.all() as ModerationCaseDetail['assignees']; .all() as ModerationCaseDetail['assignees'];
const assessmentHistory = caseAssessmentHistory(caseId);
return { return {
case: { ...row, evidence }, case: { ...row, evidence, unreadUpdates: unreadCaseUpdates(actorId, caseId) },
player: overview.players[0] ?? { player: overview.players[0] ?? {
game: row.game, game: row.game,
guid: row.guid, guid: row.guid,
@@ -77,6 +86,12 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
lastSeen: null lastSeen: null
}, },
sanctions, sanctions,
...caseActionLists(caseId, row, overview.checkedAt),
assessment: assessmentHistory[0] ?? null,
assessmentHistory,
relatedCases: relatedCases(caseId),
candidateCases: candidateCases(row),
notifications: caseNotifications(actorId, caseId),
events, events,
coverage: overview.coverage, coverage: overview.coverage,
assignees, assignees,
+128
View File
@@ -0,0 +1,128 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { matchChatTimeline } from '../fleet/chat.js';
import { moderationGuid, type ModerationGame } from '../../moderation.js';
import type { ContextualChatEntry } from '../../game-chat.js';
import { textField } from './core.js';
type Message = Extract<ContextualChatEntry, { type: 'message' }>;
/** Verify against the archive, then preserve independent excerpts across all participants. */
export function archivedEvidenceSnapshots(
caseId: string,
target: { game: ModerationGame; guid: string },
value: unknown
) {
const requested = value as Record<string, unknown> | null;
if (!requested || !Number.isSafeInteger(requested.matchId))
throw new AuthError('Choose a recorded chat message.');
const matchId = requested.matchId as number;
const match = db()
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
.get(matchId) as { game: string; server: string; started_at: string } | undefined;
if (!match || match.game !== target.game)
throw new AuthError('The source match is unavailable.', 404);
const entries = matchChatTimeline(matchId).filter(
(entry): entry is Message => entry.type === 'message'
);
const byId = new Map(entries.map((entry) => [entry.messageId!, entry]));
const isSubject = (entry: Message) =>
moderationGuid(target.game, entry.message.guid) === target.guid;
const legacy = requested.messageIds === undefined;
let selected: Message[];
if (legacy) {
textField(requested.message, 'Chat message', 4096);
selected = entries.filter(
(entry) => isSubject(entry) && entry.message.message === requested.message
);
} else {
if (
!Array.isArray(requested.messageIds) ||
!requested.messageIds.length ||
requested.messageIds.length > 100 ||
requested.messageIds.some((id) => typeof id !== 'string' || !byId.has(id))
)
throw new AuthError('Select between 1 and 100 archived message identifiers.');
const ids = new Set(requested.messageIds);
selected = entries.filter((entry) => ids.has(entry.messageId));
}
if (!selected.length || !selected.some(isSubject))
throw new AuthError(
'The original chat message could not be verified in the archive. Select a message from the case subject.',
409
);
const radius = (key: string) => {
const count = requested[key] ?? 3;
if (!Number.isSafeInteger(count) || (count as number) < 0 || (count as number) > 20)
throw new AuthError('Choose between 0 and 20 surrounding messages.');
return count as number;
};
const before = radius('contextBefore'),
after = radius('contextAfter');
const attached = new Set<string>();
const prior = db()
.prepare(
'SELECT snapshot FROM moderation_evidence WHERE case_id=? AND snapshot IS NOT NULL'
)
.all(caseId) as { snapshot: string }[];
for (const row of prior) {
try {
const snapshot = JSON.parse(row.snapshot);
if (snapshot.matchId !== matchId) continue;
if (Array.isArray(snapshot.selectedMessageIds)) {
for (const id of snapshot.selectedMessageIds)
if (typeof id === 'string') attached.add(id);
} else if (snapshot.entry?.type === 'message') {
// Older evidence predates IDs. Match each occurrence once without changing history.
const old = snapshot.entry.message;
const original = entries.find(
(entry) =>
!attached.has(entry.messageId!) &&
entry.message.guid === old.guid &&
entry.message.time === old.time &&
entry.message.player === old.player &&
entry.message.channel === old.channel &&
entry.message.message === old.message
);
if (original) attached.add(original.messageId!);
}
} catch {
/* Keep malformed legacy snapshots readable without trusting them as identities. */
}
}
const fresh = selected.filter((entry) => !attached.has(entry.messageId!));
if (!fresh.length) return [];
// Keep the original selected subject as context when only a newly selected reply is fresh.
const groups = legacy ? fresh.map((entry) => [entry]) : [selected];
return groups.map((group) => {
const indexes = new Set<number>();
for (const entry of group) {
const index = entries.indexOf(entry);
for (
let i = Math.max(0, index - before);
i <= Math.min(entries.length - 1, index + after);
i++
)
indexes.add(i);
}
const primary = group.find(isSubject)!;
return {
text: group
.filter(isSubject)
.map((entry) => entry.message.message)
.join('\n'),
source: `Archived chat · ${match.server} · match ${matchId}`,
snapshot: JSON.stringify({
version: 2,
matchId,
match,
entry: primary,
entries: [...indexes].sort((a, b) => a - b).map((index) => entries[index]),
selectedMessageIds: group
.filter((entry) => !attached.has(entry.messageId!))
.map((entry) => entry.messageId),
highlightedMessageIds: group.filter(isSubject).map((entry) => entry.messageId)
})
};
});
}
+94
View File
@@ -0,0 +1,94 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import type { ModerationCaseNotification, ModerationGame } from '../../moderation.js';
import { textField } from './core.js';
import { recordedCaseAction, isCaseActionLinked } from './case-actions.js';
/** Called once per accepted mutation, within its existing audit transaction. */
export function notifyCase(
actorId: string,
caseId: string,
kind: ModerationCaseNotification['kind'],
now: string,
previousAssignee: string | null = null
) {
db()
.prepare(
`INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at)
SELECT u.id,?,?,? FROM auth_users u JOIN moderation_cases c ON c.id=?
WHERE (u.id=c.actor_id OR u.id=c.assignee_id OR u.id=?) AND u.id<>?
AND u.role IN ('admin','moderator') AND u.disabled=0 AND u.must_change_password=0`
)
.run(caseId, kind, now, caseId, previousAssignee, actorId);
}
export function caseNotifications(actorId: string, caseId?: string): ModerationCaseNotification[] {
return db()
.prepare(
`SELECT n.id,n.case_id AS caseId,c.title AS caseTitle,n.kind,
n.created_at AS createdAt,n.read_at AS readAt FROM moderation_case_notifications n
JOIN moderation_cases c ON c.id=n.case_id WHERE n.user_id=?
${caseId === undefined ? 'AND n.read_at IS NULL' : 'AND n.case_id=?'} ORDER BY n.id DESC LIMIT 25`
)
.all(
...(caseId === undefined ? [actorId] : [actorId, caseId])
) as ModerationCaseNotification[];
}
export function unreadCaseUpdates(actorId: string, caseId?: string): number {
return (
db()
.prepare(
`SELECT COUNT(*) AS count FROM moderation_case_notifications
WHERE user_id=? AND read_at IS NULL${caseId === undefined ? '' : ' AND case_id=?'}`
)
.get(...(caseId === undefined ? [actorId] : [actorId, caseId])) as { count: number }
).count;
}
export function readCaseUpdates(actorId: string, caseId: string, throughId: unknown, now: string) {
if (!Number.isSafeInteger(throughId) || (throughId as number) < 0)
throw new AuthError('Choose the last update you have reviewed.');
db()
.prepare(
`UPDATE moderation_case_notifications SET read_at=?
WHERE user_id=? AND case_id=? AND id<=? AND read_at IS NULL`
)
.run(now, actorId, caseId, throughId);
}
function canonicalDate(value: unknown): string {
if (
typeof value !== 'string' ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value
)
throw new AuthError('Choose a valid review date and time.');
return value;
}
export function caseReviewSchedule(
subject: { id: string; game: ModerationGame; guid: string },
input: Record<string, unknown>,
now: string
) {
const reason = textField(input.reason, 'Review reason', 4000);
if ((input.reviewAt === undefined) === (input.restrictionId === undefined))
throw new AuthError('Choose either a review date or a linked mute.');
if (input.restrictionId !== undefined) {
const id = textField(input.restrictionId, 'Restriction ID', 128);
const reference = { type: 'restriction' as const, id };
const restriction = recordedCaseAction(subject, reference, now);
if (!isCaseActionLinked(subject.id, reference))
throw new AuthError('Link the mute to this case before scheduling its review.', 409);
if (restriction.kind !== 'mute' || !restriction.expiresAt)
throw new AuthError('Choose a linked mute with a recorded expiry time.');
return { reviewAt: canonicalDate(restriction.expiresAt), reason, restrictionId: id };
}
return {
reviewAt: input.reviewAt === null ? null : canonicalDate(input.reviewAt),
reason,
restrictionId: null
};
}
+32 -6
View File
@@ -1,4 +1,6 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { currentCaseAssessment } from './assessments.js';
import { caseNotifications, unreadCaseUpdates } from './followups.js';
import { moderationActor, moderationNow, policyRevision } from './core.js'; 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';
@@ -22,6 +24,7 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
const offset = Number(params.get('offset') ?? 0); const offset = Number(params.get('offset') ?? 0);
const caseStatus = params.get('caseStatus') ?? ''; const caseStatus = params.get('caseStatus') ?? '';
const priority = params.get('priority') ?? ''; const priority = params.get('priority') ?? '';
const review = params.get('review') ?? '';
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),
@@ -34,7 +37,10 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
priority: ['low', 'normal', 'high', 'urgent'].includes(priority) priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
? (priority as ModerationQuery['priority']) ? (priority as ModerationQuery['priority'])
: '', : '',
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128) assignedTo: (params.get('assignedTo') ?? '').slice(0, 128),
review: ['due', 'scheduled', 'updates'].includes(review)
? (review as ModerationQuery['review'])
: ''
}; };
} }
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview { export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
@@ -89,7 +95,9 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
coverage, coverage,
total: 0, total: 0,
limit, limit,
checkedAt: now checkedAt: now,
notifications: caseNotifications(actorId),
unreadCount: unreadCaseUpdates(actorId)
}; };
const where: string[] = [], const where: string[] = [],
values: (string | number)[] = []; values: (string | number)[] = [];
@@ -121,6 +129,16 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
where.push('p.assignee_id=?'); where.push('p.assignee_id=?');
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo); values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
} }
if (query.review === 'due') {
where.push('p.review_at IS NOT NULL AND p.review_at<=?');
values.push(now);
} else if (query.review === 'scheduled') where.push('p.review_at IS NOT NULL');
else if (query.review === 'updates') {
where.push(
'EXISTS (SELECT 1 FROM moderation_case_notifications n WHERE n.case_id=p.id AND n.user_id=? AND n.read_at IS NULL)'
);
values.push(actorId);
}
} }
if (query.q) { if (query.q) {
const extra = const extra =
@@ -137,7 +155,12 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
values.push(query.q, query.q, query.q); values.push(query.q, query.q, query.q);
} }
const clause = where.length ? ' WHERE ' + where.join(' AND ') : ''; const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
function rows<T>(table: string, select: string, order: string): T[] { function rows<T>(
table: string,
select: string,
order: string,
orderValues: string[] = []
): T[] {
result.total = ( result.total = (
handle handle
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`) .prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
@@ -151,7 +174,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
.prepare( .prepare(
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?` `SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
) )
.all(...values, limit, result.query.offset) as T[]; .all(...values, ...orderValues, limit, result.query.offset) as T[];
} }
if (query.view === 'players') { if (query.view === 'players') {
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>( result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
@@ -196,10 +219,13 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
} 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.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.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.review_at AS reviewAt,p.review_reason AS reviewReason,p.review_sanction_id AS reviewSanctionId',
"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" "CASE WHEN p.review_at<=? THEN 0 ELSE 1 END,CASE WHEN p.review_at<=? THEN p.review_at END,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",
[now, now]
).map((c) => ({ ).map((c) => ({
...c, ...c,
assessment: currentCaseAssessment(c.id),
unreadUpdates: unreadCaseUpdates(actorId, c.id),
evidence: handle evidence: handle
.prepare( .prepare(
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id' 'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
+212
View File
@@ -0,0 +1,212 @@
import { randomUUID } from 'node:crypto';
import type { AuthUser } from '../../auth.js';
import {
moderationCaseRelations,
moderationGuid,
type ModerationCaseCandidate,
type ModerationCaseReference,
type ModerationCaseRelation,
type ModerationCaseRelationship
} from '../../moderation.js';
import { AuthError } from '../auth/index.js';
import { db } from '../db/index.js';
import { moderationEvent, textField } from './core.js';
type RelationshipRow = {
id: string;
case_id: string;
target_case_id: string;
relation: ModerationCaseRelation;
removed_at: string | null;
};
function relationAudit(
actor: AuthUser,
relationship: RelationshipRow,
cases: ModerationCaseReference[],
action: 'case.linked' | 'case.unlinked',
reason: string,
now: string
) {
db()
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id IN (?,?)')
.run(now, relationship.case_id, relationship.target_case_id);
for (const item of cases) {
const outgoing = item.id === relationship.case_id;
moderationEvent(
actor,
action,
item,
item.id,
JSON.stringify({
relationshipId: relationship.id,
relation: relationship.relation,
direction: outgoing ? 'outgoing' : 'incoming',
relatedCaseId: outgoing ? relationship.target_case_id : relationship.case_id,
reason
}),
now
);
}
}
export function linkCases(
actor: AuthUser,
source: ModerationCaseReference,
target: ModerationCaseReference,
value: unknown,
reasonValue: unknown,
now: string
) {
const relation = moderationCaseRelations.find((relation) => relation === value);
if (!relation) throw new AuthError('Choose same incident, appeal of, or duplicate of.');
const reason = textField(reasonValue, 'Relationship reason', 4000);
if (source.id === target.id) throw new AuthError('A case cannot be related to itself.');
if (
relation !== 'same_incident' &&
(source.game !== target.game || source.guid !== target.guid)
)
throw new AuthError('Appeal and duplicate relationships require the same game and player.');
const [from, to] =
relation === 'same_incident' && source.id > target.id ? [target, source] : [source, target];
const handle = db();
if (
handle
.prepare(
`SELECT 1 FROM moderation_case_relationships
WHERE case_id=? AND target_case_id=? AND relation=? AND removed_at IS NULL`
)
.get(from.id, to.id, relation)
)
return;
if (
relation !== 'same_incident' &&
handle
.prepare(
`WITH RECURSIVE descendants(id) AS (
SELECT target_case_id FROM moderation_case_relationships
WHERE case_id=? AND relation=? AND removed_at IS NULL
UNION
SELECT r.target_case_id FROM moderation_case_relationships r
JOIN descendants d ON r.case_id=d.id WHERE r.relation=? AND r.removed_at IS NULL
) SELECT 1 FROM descendants WHERE id=? LIMIT 1`
)
.get(to.id, relation, relation, from.id)
)
throw new AuthError(
'This relationship would create a cycle. Review the linked cases.',
409
);
const relationship: RelationshipRow = {
id: randomUUID(),
case_id: from.id,
target_case_id: to.id,
relation,
removed_at: null
};
handle
.prepare('INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)')
.run(relationship.id, from.id, to.id, relation, actor.id, actor.username, now);
relationAudit(actor, relationship, [from, to], 'case.linked', reason, now);
}
export function unlinkCases(
actor: AuthUser,
source: ModerationCaseReference,
value: unknown,
reasonValue: unknown,
now: string
) {
const id = textField(value, 'Relationship ID', 128);
const reason = textField(reasonValue, 'Removal reason', 4000);
const handle = db();
const relationship = handle
.prepare(
`SELECT id,case_id,target_case_id,relation,removed_at
FROM moderation_case_relationships WHERE id=? AND (case_id=? OR target_case_id=?)`
)
.get(id, source.id, source.id) as RelationshipRow | undefined;
if (!relationship) throw new AuthError('The case relationship is unavailable.', 404);
if (relationship.removed_at) return;
const otherId =
relationship.case_id === source.id ? relationship.target_case_id : relationship.case_id;
const other = handle
.prepare('SELECT id,title,status,game,guid FROM moderation_cases WHERE id=?')
.get(otherId) as ModerationCaseReference;
handle.prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?').run(now, id);
relationAudit(actor, relationship, [source, other], 'case.unlinked', reason, now);
}
/** Relationships remain readable from both endpoints, with the original direction intact. */
export function relatedCases(caseId: string): ModerationCaseRelationship[] {
const rows = db()
.prepare(
`SELECT r.id,r.relation,r.actor,r.created_at AS createdAt,
CASE WHEN r.case_id=? THEN 'outgoing' ELSE 'incoming' END AS direction,
c.id AS otherId,c.title,c.status,c.game,c.guid
FROM moderation_case_relationships r JOIN moderation_cases c
ON c.id=CASE WHEN r.case_id=? THEN r.target_case_id ELSE r.case_id END
WHERE (r.case_id=? OR r.target_case_id=?) AND r.removed_at IS NULL
ORDER BY r.created_at DESC,r.id`
)
.all(caseId, caseId, caseId, caseId) as Array<
Omit<ModerationCaseRelationship, 'case'> &
Omit<ModerationCaseReference, 'id'> & { otherId: string }
>;
return rows.map(({ otherId, title, status, game, guid, ...relationship }) => ({
...relationship,
case: { id: otherId, title, status, game, guid }
}));
}
function archivedMatch(value: string, subject: ModerationCaseReference): number | null {
try {
const snapshot = JSON.parse(value);
if (
!Number.isSafeInteger(snapshot?.matchId) ||
snapshot.matchId < 1 ||
snapshot.match?.game !== subject.game ||
typeof snapshot.match?.server !== 'string' ||
typeof snapshot.match?.started_at !== 'string' ||
snapshot.entry?.type !== 'message' ||
typeof snapshot.entry.message?.guid !== 'string' ||
typeof snapshot.entry.message?.message !== 'string' ||
moderationGuid(subject.game, snapshot.entry.message.guid) !== subject.guid
)
return null;
return snapshot.matchId;
} catch {
return null;
}
}
/** Sharing archived match context is a review hint, never a duplicate decision. */
export function candidateCases(subject: ModerationCaseReference): ModerationCaseCandidate[] {
const handle = db();
const candidates = handle
.prepare(
`SELECT id,title,status,game,guid FROM moderation_cases
WHERE game=? AND guid=? AND id<>? ORDER BY updated_at DESC,id LIMIT 25`
)
.all(subject.game, subject.guid, subject.id) as ModerationCaseReference[];
const ids = [subject.id, ...candidates.map((candidate) => candidate.id)];
const snapshots = handle
.prepare(
`SELECT case_id,snapshot FROM moderation_evidence
WHERE case_id IN (${ids.map(() => '?').join(',')}) AND snapshot IS NOT NULL`
)
.all(...ids) as { case_id: string; snapshot: string }[];
const matches = new Map<string, Set<number>>();
for (const snapshot of snapshots) {
const match = archivedMatch(snapshot.snapshot, subject);
if (match === null) continue;
const set = matches.get(snapshot.case_id) ?? new Set<number>();
set.add(match);
matches.set(snapshot.case_id, set);
}
const current = matches.get(subject.id) ?? new Set<number>();
return candidates.map((candidate) => ({
...candidate,
sharedMatch: [...(matches.get(candidate.id) ?? [])].some((match) => current.has(match))
}));
}
+86
View File
@@ -149,3 +149,89 @@ export function migrateModerationCases(handle: Database.Database): void {
CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id); CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id);
`); `);
} }
/** Links record prior enforcement without changing its original case or issuing it again. */
export function migrateModerationCaseActions(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_action_links (
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
action_type TEXT NOT NULL CHECK(action_type IN ('action','restriction')),
action_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(case_id,action_type,action_id)
);
CREATE INDEX IF NOT EXISTS moderation_case_action_source
ON moderation_case_action_links(action_type,action_id);
`);
}
export function migrateModerationCaseAssessments(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_assessments (
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
revision INTEGER NOT NULL CHECK(revision > 0),
data TEXT NOT NULL,
actor_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(case_id,revision)
);
`);
}
export function migrateModerationCaseRelationships(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_relationships (
id TEXT PRIMARY KEY,
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
target_case_id TEXT NOT NULL REFERENCES moderation_cases(id),
relation TEXT NOT NULL CHECK(relation IN ('same_incident','appeal_of','duplicate_of')),
actor_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
removed_at TEXT,
CHECK(case_id != target_case_id),
CHECK(relation != 'same_incident' OR case_id < target_case_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS moderation_case_relationship_active
ON moderation_case_relationships(case_id,target_case_id,relation) WHERE removed_at IS NULL;
CREATE INDEX IF NOT EXISTS moderation_case_relationship_target
ON moderation_case_relationships(target_case_id,removed_at);
CREATE INDEX IF NOT EXISTS moderation_case_subject_recent
ON moderation_cases(game,guid,updated_at DESC,id);
`);
}
export function migrateModerationCaseFollowups(handle: Database.Database): void {
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
if (!columns.some((column) => column.name === 'review_at'))
handle.exec(`ALTER TABLE moderation_cases ADD COLUMN review_at TEXT;
ALTER TABLE moderation_cases ADD COLUMN review_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE moderation_cases ADD COLUMN review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL;`);
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_case_review_due ON moderation_cases(review_at) WHERE review_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS moderation_case_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
kind TEXT NOT NULL CHECK(kind IN ('evidence','assignment')),
created_at TEXT NOT NULL,
read_at TEXT
);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_unread ON moderation_case_notifications(user_id,read_at,id DESC);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_case ON moderation_case_notifications(user_id,case_id,read_at,id DESC);
`);
}
/** Foreign-key lookups must cover removed relationships and every notification recipient. */
export function migrateModerationCaseReferenceIndexes(handle: Database.Database): void {
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_case_review_sanction
ON moderation_cases(review_sanction_id);
CREATE INDEX IF NOT EXISTS moderation_case_relationship_source
ON moderation_case_relationships(case_id,removed_at);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_parent
ON moderation_case_notifications(case_id);
`);
}
+142 -4
View File
@@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js'; import { canModerate, canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js'; import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js'; import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
import { formatDuration } from '$ui/duration.js'; import { formatDuration } from '$ui/duration.js';
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js'; import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import Freshness from './Freshness.svelte'; import Freshness from './Freshness.svelte';
import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte';
let { let {
server, server,
@@ -40,6 +42,41 @@
let follow = $state(true); let follow = $state(true);
let paused = $state(false); let paused = $state(false);
let controller: AbortController | null = null; let controller: AbortController | null = null;
const selectableMessages = $derived(
history !== undefined &&
canModerate($page.data?.user?.role) &&
(moderationGame === 't5' || moderationGame === 't6')
? entries.filter(
(entry) => entry.type === 'message' && entry.messageId && entry.matchId
)
: []
);
let selectedMessageIds = $state<string[]>([]);
let evidenceOpen = $state(false);
let attachedCaseId = $state('');
const selectedMatchId = $derived(
selectableMessages.find(
(entry) => entry.messageId && selectedMessageIds.includes(entry.messageId)
)?.matchId
);
const selectionCandidates = $derived(
selectableMessages.filter(
(entry) => entry.matchId === (selectedMatchId ?? selectableMessages[0]?.matchId)
)
);
$effect(() => {
void history;
selectedMessageIds = [];
evidenceOpen = false;
attachedCaseId = '';
});
function toggleEvidence(messageId: string) {
selectedMessageIds = selectedMessageIds.includes(messageId)
? selectedMessageIds.filter((id) => id !== messageId)
: [...selectedMessageIds, messageId];
}
async function refresh(manual = false) { async function refresh(manual = false) {
if (!server || hostOffline) return; if (!server || hostOffline) return;
@@ -175,6 +212,36 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if selectableMessages.length}
<div class="evidence-toolbar" aria-label="Chat evidence selection">
<span>{selectedMessageIds.length} selected</span>
<Button
size="sm"
onclick={() =>
(selectedMessageIds = selectionCandidates
.slice(0, 100)
.map((entry) => entry.messageId!))}
>Select {selectionCandidates.length > 100 ? 'first 100' : 'all'}</Button
>
<Button
size="sm"
disabled={!selectedMessageIds.length}
onclick={() => (selectedMessageIds = [])}>Clear selection</Button
>
<Button
size="sm"
variant="primary"
disabled={!selectedMessageIds.length}
onclick={() => (evidenceOpen = true)}>Attach to case</Button
>
{#if selectedMessageIds.length >= 100}<span
>Attach up to 100 selected messages at a time.</span
>{/if}
</div>
{/if}
{#if attachedCaseId}<p class="evidence-notice" role="status">
Selected evidence saved. <a href={moderationCaseHref(attachedCaseId)}>View case</a>
</p>{/if}
{#if hostOffline}<p class="outage"> {#if hostOffline}<p class="outage">
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
? ' Showing the last loaded messages.' ? ' Showing the last loaded messages.'
@@ -236,7 +303,11 @@
{@render matchBoundary(item.entries[item.entries.length - 1])} {@render matchBoundary(item.entries[item.entries.length - 1])}
</li> </li>
{:else} {:else}
<li class="message"> <li
class="message"
class:selected={!!item.entry.messageId &&
selectedMessageIds.includes(item.entry.messageId)}
>
<span class="message-clock"> <span class="message-clock">
{#if item.entry.context.realTime}{@render clockTime( {#if item.entry.context.realTime}{@render clockTime(
item.entry.context.realTime item.entry.context.realTime
@@ -246,7 +317,31 @@
</span> </span>
<div class="message-content"> <div class="message-content">
<div class="message-head"> <div class="message-head">
<strong>{item.entry.message.player}</strong {#if selectableMessages.length && item.entry.messageId && item.entry.matchId}
<label class="evidence-select">
<input
type="checkbox"
checked={selectedMessageIds.includes(
item.entry.messageId
)}
disabled={!selectedMessageIds.includes(
item.entry.messageId
) &&
(selectedMessageIds.length >= 100 ||
(!!selectedMatchId &&
selectedMatchId !==
item.entry.matchId))}
onchange={() =>
toggleEvidence(item.entry.messageId!)}
aria-label={`Select message by ${item.entry.message.player} at ${item.entry.message.time}: ${item.entry.message.message}`}
/>
<span class="sr-only">Select evidence</span>
</label>
{/if}
<strong
>{item.entry.message
.player}{#if item.entry.message.channel === 'team'}
<Badge>Team chat</Badge>{/if}</strong
>{@render chatContext(item.entry.context)} >{@render chatContext(item.entry.context)}
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a {#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
class="moderation-link" class="moderation-link"
@@ -271,6 +366,20 @@
{/if} {/if}
</section> </section>
{#if evidenceOpen && selectedMatchId && (moderationGame === 't5' || moderationGame === 't6')}
<MatchEvidenceDialog
bind:open={evidenceOpen}
game={moderationGame}
matchId={selectedMatchId}
entries={entries.filter((entry) => entry.matchId === selectedMatchId)}
{selectedMessageIds}
onsubmitted={(id) => {
attachedCaseId = id;
selectedMessageIds = [];
}}
/>
{/if}
<style> <style>
section { section {
container-type: inline-size; container-type: inline-size;
@@ -283,6 +392,31 @@
gap: var(--space-3); gap: var(--space-3);
flex-wrap: wrap; flex-wrap: wrap;
} }
.evidence-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-notice {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-select {
display: inline-flex;
align-items: center;
min-width: 24px;
min-height: 24px;
}
.evidence-select input {
accent-color: var(--accent-primary);
}
.message.selected {
background: var(--surface-raised);
}
.chat-head { .chat-head {
justify-content: space-between; justify-content: space-between;
} }
@@ -488,5 +622,9 @@
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 4; grid-row: 4;
} }
.evidence-select {
grid-column: 1 / -1;
grid-row: 5;
}
} }
</style> </style>
+53 -12
View File
@@ -5,6 +5,7 @@
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js'; import { timestampISO } from '$lib/time-zone.js';
@@ -42,6 +43,7 @@
let navigationId = 0; let navigationId = 0;
const now = $derived(browser ? $uiNow : Date.now()); const now = $derived(browser ? $uiNow : Date.now());
const filters = $derived(readMatchQuery(matchApiParams($page.url))); const filters = $derived(readMatchQuery(matchApiParams($page.url)));
const canViewArchive = $derived(canReadModeration($page.data?.user?.role));
const apiUrl = $derived.by(() => { const apiUrl = $derived.by(() => {
const params = matchApiParams($page.url); const params = matchApiParams($page.url);
params.set('limit', String(matchPageSize)); params.set('limit', String(matchPageSize));
@@ -131,6 +133,19 @@
</script> </script>
<section class="history" aria-label="Match history"> <section class="history" aria-label="Match history">
{#if canViewArchive}<div class="archive-control">
<label
><input
type="checkbox"
checked={filters.archive}
onchange={(event) =>
update({ archive: event.currentTarget.checked ? '1' : '' })}
/> Moderation archive</label
>
{#if filters.archive}<p>
Includes round-one, interrupted, and unfinished matches for evidence review.
</p>{/if}
</div>{/if}
{#snippet filterFields(suffix: string)} {#snippet filterFields(suffix: string)}
<div class="filters"> <div class="filters">
{#if server === undefined}<Field forId={`match-game${suffix}`} label="Game"> {#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
@@ -357,9 +372,12 @@
<tr> <tr>
<td><RelativeTime value={match.startedAt} /></td> <td><RelativeTime value={match.startedAt} /></td>
<td class="match"> <td class="match">
{#if match.status === 'completed'}<a {#if match.status === 'completed' || (canViewArchive && filters.archive)}<a
class="match-link" class="match-link"
href={contextHref(`/matches/${match.id}`, $page.url)} href={contextHref(
`/matches/${match.id}${canViewArchive && filters.archive ? '?matchArchive=1' : ''}`,
$page.url
)}
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a >{match.map} · Match #{match.id}</a
> >
@@ -379,7 +397,8 @@
? ` · ${duration}` ? ` · ${duration}`
: ''}</small : ''}</small
> >
{#if notReporting}<small class="mobile-match-context" {#if notReporting || (filters.archive && match.status !== 'completed')}<small
class="mobile-match-context"
>Last report <RelativeTime >Last report <RelativeTime
value={match.lastReportedAt} value={match.lastReportedAt}
missing="Not recorded" missing="Not recorded"
@@ -403,7 +422,7 @@
class:interrupted={match.status === 'interrupted' || class:interrupted={match.status === 'interrupted' ||
match.status === 'end-report-missing' || match.status === 'end-report-missing' ||
notReporting}>{status}</span notReporting}>{status}</span
>{#if notReporting}<small >{#if notReporting || (filters.archive && match.status !== 'completed')}<small
>Last report <RelativeTime >Last report <RelativeTime
value={match.lastReportedAt} value={match.lastReportedAt}
missing="Not recorded" missing="Not recorded"
@@ -469,14 +488,16 @@
<summary>About match history</summary> <summary>About match history</summary>
<p> <p>
Players counts unique players across all reports. A dash means the value is unavailable, Players counts unique players across all reports. A dash means the value is unavailable,
not zero. In-progress matches show elapsed time since their recorded start and link to not zero. In-progress matches show elapsed time since their recorded start. {#if filters.archive}Archive
the live server while reports are current. Otherwise, they show Not reporting with the links open retained match reports and chat.{:else}They link to the live server while
last report time. Completed durations require a recorded start and completed ending. End reports are current.{/if} Otherwise, they show Not reporting with the last report time.
report missing means a newer game was reported before this game's end report arrived. Completed durations require a recorded start and completed ending. End report missing means
Missing report counts show gaps in the reporter's sequence and can decrease as delayed a newer game was reported before this game's end report arrived. Missing report counts show
reports arrive. Only completed games contribute to the leaderboard. Round shows the gaps in the reporter's sequence and can decrease as delayed reports arrive. Only completed
final round for completed matches and the highest observed round for unfinished matches. games contribute to the leaderboard. Round shows the final round for completed matches and
Open a completed match for exact timestamps and starting and ending player counts. the highest observed round for unfinished matches. Open a {filters.archive
? 'recorded'
: 'completed'} match for timestamps and participant details.
</p> </p>
</details> </details>
</section> </section>
@@ -487,6 +508,26 @@
gap: var(--space-4); gap: var(--space-4);
min-width: 0; min-width: 0;
} }
.archive-control {
display: grid;
gap: var(--space-2);
font-size: var(--text-sm);
}
.archive-control label {
display: flex;
align-items: center;
gap: var(--space-2);
width: fit-content;
cursor: pointer;
}
.archive-control input {
accent-color: var(--accent-primary);
}
.archive-control p {
margin: 0;
color: var(--text-secondary);
line-height: var(--leading-body);
}
.filters { .filters {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -0,0 +1,167 @@
<script lang="ts">
import {
moderationConcernLabels,
moderationFindingLabels,
type ModerationCaseAssessment
} from '$lib/moderation.js';
import Button from '../primitives/Button.svelte';
import Badge from '../primitives/Badge.svelte';
import RelativeTime from '../RelativeTime.svelte';
import CaseAssessmentDialog from './CaseAssessmentDialog.svelte';
let {
caseId,
revision,
assessment = null,
history = [],
permitted,
onsubmitted
}: {
caseId: string;
revision: number;
assessment?: ModerationCaseAssessment | null;
history?: ModerationCaseAssessment[];
permitted: boolean;
onsubmitted: () => void;
} = $props();
let open = $state(false);
const previous = $derived(history.filter((item) => item.revision !== assessment?.revision));
$effect(() => {
void caseId;
open = false;
});
</script>
{#snippet assessmentContent(value: ModerationCaseAssessment)}
<p class="assessment-summary">{value.summary}</p>
<div>
<h3>Applicable rules</h3>
{#if value.rules.length}<ul class="rules">
{#each value.rules as rule}<li>{rule}</li>{/each}
</ul>{:else}<p class="hint">No rule references recorded.</p>{/if}
</div>
<ol class="findings" aria-label="Structured findings">
{#each value.findings as finding}<li>
<div class="heading">
<strong>{moderationConcernLabels[finding.category]}</strong><Badge
variant={finding.finding === 'substantiated'
? 'danger'
: finding.finding === 'inconclusive'
? 'warning'
: 'neutral'}>{moderationFindingLabels[finding.finding]}</Badge
>
</div>
<p>{finding.detail}</p>
</li>{/each}
</ol>
<p class="hint">
Revision {value.revision} · {value.actor} · <RelativeTime value={value.createdAt} />
</p>
{/snippet}
<section class="assessment" aria-label="Case assessment">
<div class="heading">
<h2>Case assessment</h2>
{#if permitted}<Button size="sm" onclick={() => (open = true)}
>{assessment ? 'Edit assessment' : 'Assess case'}</Button
>{/if}
</div>
{#if assessment}{@render assessmentContent(assessment)}{:else}<p class="hint">
Not assessed. Record concerns, applicable rules, and what the evidence supports.
</p>{/if}
{#if previous.length}<details>
<summary>Previous assessments ({previous.length})</summary>
<ol class="history">
{#each previous as earlier (earlier.revision)}<li>
{@render assessmentContent(earlier)}
</li>{/each}
</ol>
</details>{/if}
</section>
{#if permitted && open}<CaseAssessmentDialog
bind:open
{caseId}
{revision}
{assessment}
{onsubmitted}
/>{/if}
<style>
.assessment {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-lg);
}
h3 {
font-size: var(--text-sm);
}
p,
.rules {
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.assessment-summary,
.findings p {
white-space: pre-wrap;
}
.rules {
padding-left: var(--space-5);
margin: var(--space-2) 0 0;
}
.findings,
.history {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-3);
}
.findings > li {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
background: var(--surface-sunken);
border-radius: var(--radius-md);
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
details {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-3);
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
.history {
margin-top: var(--space-4);
}
.history > li {
display: grid;
gap: var(--space-3);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
</style>
@@ -0,0 +1,260 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
moderationConcernCategories,
moderationConcernLabels,
moderationFindingOutcomes,
moderationFindingLabels,
type ModerationCaseAssessment,
type ModerationConcernCategory,
type ModerationFindingOutcome
} from '$lib/moderation.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
interface DraftFinding {
category: ModerationConcernCategory | '';
finding: ModerationFindingOutcome | '';
detail: string;
key: number;
}
let {
open = $bindable(false),
caseId,
revision,
assessment,
onsubmitted
}: {
open?: boolean;
caseId: string;
revision: number;
assessment: ModerationCaseAssessment | null;
onsubmitted: () => void;
} = $props();
const openedRevision = untrack(() => revision);
let summary = $state(untrack(() => assessment?.summary ?? ''));
let rulesText = $state(untrack(() => assessment?.rules.join('\n') ?? ''));
let nextKey = 0;
let findings = $state<DraftFinding[]>(
untrack(
() =>
assessment?.findings.map((finding) => ({ ...finding, key: nextKey++ })) ?? [
{ category: '', finding: '', detail: '', key: nextKey++ }
]
)
);
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending) {
const rules = rulesText
.split('\n')
.map((rule) => rule.trim())
.filter(Boolean);
if (
!summary.trim() ||
!findings.length ||
findings.some((item) => !item.category || !item.finding || !item.detail.trim())
) {
error = 'Add a summary and complete each concern, finding, and explanation.';
return;
}
if (rules.length > 20 || rules.some((rule) => rule.length > 160)) {
error = 'Use up to 20 applicable rules, with no more than 160 characters per line.';
return;
}
pending = {
op: 'assess-case',
id: caseId,
revision: openedRevision,
summary: summary.trim(),
rules,
findings: findings.map(({ category, finding, detail }) => ({
category,
finding,
detail: detail.trim()
})),
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
throw new Error(result?.error || 'Could not save the assessment.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error = cause instanceof Error ? cause.message : 'Could not save the assessment.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={assessment ? 'Edit case assessment' : 'Assess case'}
description="Record the current evidence assessment. Each saved revision is preserved in the case history."
{busy}
wide
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field label="Current summary" required
><Textarea bind:value={summary} required rows={3} maxlength={2000} /></Field
>
<Field
label="Applicable rules"
hint="One rule or policy reference per line. Up to 20 lines, 160 characters each."
><Textarea bind:value={rulesText} rows={3} maxlength={3220} /></Field
>
<div class="findings">
{#each findings as item, index (item.key)}
<section class="finding" aria-label={`Finding ${index + 1}`}>
<div class="finding-heading">
<h3>Finding {index + 1}</h3>
<Button
size="sm"
disabled={findings.length === 1}
ariaLabel={`Remove finding ${index + 1}`}
onclick={() =>
(findings = findings.filter(
(finding) => finding.key !== item.key
))}>Remove</Button
>
</div>
<div class="paired">
<Field label={`Concern ${index + 1}`} required
><Select
bind:value={item.category}
required
options={[
{ value: '', label: 'Select a concern' },
...moderationConcernCategories.map((category) => ({
value: category,
label: moderationConcernLabels[category],
disabled: findings.some(
(other) =>
other.key !== item.key &&
other.category === category
)
}))
]}
/></Field
>
<Field label={`Finding ${index + 1}`} required
><Select
bind:value={item.finding}
required
options={[
{ value: '', label: 'Select a finding' },
...moderationFindingOutcomes.map((finding) => ({
value: finding,
label: moderationFindingLabels[finding]
}))
]}
/></Field
>
</div>
<Field
label={`Explanation ${index + 1}`}
required
hint="Explain how the evidence supports this finding, including uncertainty."
><Textarea
bind:value={item.detail}
required
rows={3}
maxlength={1000}
/></Field
>
</section>
{/each}
<Button
size="sm"
disabled={findings.length >= moderationConcernCategories.length}
onclick={() =>
(findings = [
...findings,
{ category: '', finding: '', detail: '', key: nextKey++ }
])}>Add finding</Button
>
</div>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same assessment safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}>{uncertain ? 'Retry same request' : 'Save assessment'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset,
.findings,
.finding {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
.finding {
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.finding-heading,
.actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
h3 {
margin: 0;
font-size: var(--text-base);
}
.paired {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.actions {
justify-content: flex-end;
}
.hint {
font-size: var(--text-sm);
line-height: var(--leading-body);
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
import Button from '../primitives/Button.svelte';
import CaseReviewDialog from './CaseReviewDialog.svelte';
let {
item,
actions = [],
checkedAt,
permitted,
onsubmitted
}: {
item: ModerationCase;
actions?: ModerationCaseAction[];
checkedAt: string;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let open = $state(false);
let clearing = $state(false);
const overdue = $derived(!!item.reviewAt && Date.parse(item.reviewAt) <= Date.parse(checkedAt));
$effect(() => {
void item.id;
open = false;
});
</script>
<section aria-label="Follow-up review">
<div class="heading">
<h2>Follow-up review</h2>
{#if overdue}<Badge variant="warning">Overdue review</Badge>{/if}
</div>
{#if item.reviewAt}<p>
<time datetime={item.reviewAt}>{$timeFormat.dateTime(item.reviewAt)}</time>
</p>
{#if item.reviewSanctionId}<p class="hint">Review after linked mute expires.</p>{/if}
{:else}<p class="hint">No review scheduled.</p>{/if}
{#if item.reviewReason}<p class="reason">{item.reviewReason}</p>{/if}
{#if permitted}<div class="actions">
<Button
size="sm"
onclick={() => {
clearing = false;
open = true;
}}>{item.reviewAt ? 'Reschedule review' : 'Schedule review'}</Button
>
{#if item.reviewAt}<Button
size="sm"
onclick={() => {
clearing = true;
open = true;
}}>Complete or clear review</Button
>{/if}
</div>{/if}
</section>
{#if permitted && open}<CaseReviewDialog bind:open {item} {actions} {clearing} {onsubmitted} />{/if}
<style>
section {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading,
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.heading {
justify-content: space-between;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
margin: 0;
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.reason {
white-space: pre-wrap;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
</style>
@@ -0,0 +1,193 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
moderationCaseRelations,
moderationCaseRelationLabels,
type ModerationCaseDetail,
type ModerationCaseRelationship,
type ModerationCaseRelation
} from '$lib/moderation.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
let {
open = $bindable(false),
caseId,
revision,
candidates = [],
relationship,
onsubmitted
}: {
open?: boolean;
caseId: string;
revision: number;
candidates?: NonNullable<ModerationCaseDetail['candidateCases']>;
relationship?: ModerationCaseRelationship;
onsubmitted: () => void;
} = $props();
const openedRevision = untrack(() => revision);
let selected = $state('');
let caseReference = $state('');
let relation = $state<ModerationCaseRelation>('same_incident');
let reason = $state('');
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
function relatedId(): string {
if (selected) return selected;
const value = caseReference.trim();
if (value.startsWith('/') || /^https?:\/\//i.test(value)) {
const path = new URL(value, 'http://localhost').pathname;
const match = /^\/moderation\/cases\/([^/]+)\/?$/.exec(path);
if (!match) throw new Error('Enter a case ID or a moderation case URL.');
return decodeURIComponent(match[1]);
}
return value;
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
error = '';
if (!pending) {
try {
const target = relationship ? '' : relatedId();
if ((!relationship && !target) || !reason.trim())
throw new Error('Choose a related case and provide a reason for this change.');
pending = {
id: caseId,
revision: openedRevision,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID(),
...(relationship
? { op: 'unlink-case', relationshipId: relationship.id }
: { op: 'link-case', relatedCaseId: target, relation })
};
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Enter a valid case reference.';
return;
}
}
busy = true;
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
throw new Error(result?.error || 'Could not save the case relationship.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not save the case relationship.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={relationship ? 'Remove case relationship' : 'Link related case'}
{busy}
description="Relationships connect case records while preserving each case and its history."
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
{#if relationship}<p>
Remove the relationship with <strong>{relationship.case.title}</strong>. The
relationship history remains recorded.
</p>
{:else}
<Field label="Related case"
><Select
bind:value={selected}
options={[
{ value: '', label: 'Enter a case ID or URL' },
...candidates.map((item) => ({
value: item.id,
label: `${item.title}${item.sharedMatch ? ' · Shared archived match' : ''}`
}))
]}
/></Field
>
{#if !selected}<Field label="Case ID or URL" required
><Input bind:value={caseReference} required maxlength={2048} /></Field
>{/if}
<Field label="Relationship"
><Select
bind:value={relation}
options={moderationCaseRelations.map((value) => ({
value,
label: moderationCaseRelationLabels[value]
}))}
/></Field
>
<p class="hint">
Appeal and duplicate relationships refer to the same player. “Same incident” can
connect cases involving different players.
</p>
{/if}
<Field label="Relationship reason" required
><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same request safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}
>{uncertain
? 'Retry same request'
: relationship
? 'Remove relationship'
: 'Link cases'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
p {
margin: 0;
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
</style>
@@ -0,0 +1,153 @@
<script lang="ts">
import {
moderationCaseRelationLabels,
moderationCaseStatusLabels,
type ModerationCaseDetail,
type ModerationCaseRelationship
} from '$lib/moderation.js';
import { moderationCaseHref } from '$ui/moderation.js';
import Button from '../primitives/Button.svelte';
import Badge from '../primitives/Badge.svelte';
import RelativeTime from '../RelativeTime.svelte';
import CaseRelationshipDialog from './CaseRelationshipDialog.svelte';
let {
caseId,
revision,
relationships = [],
candidates = [],
permitted,
onsubmitted
}: {
caseId: string;
revision: number;
relationships?: ModerationCaseRelationship[];
candidates?: NonNullable<ModerationCaseDetail['candidateCases']>;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let open = $state(false);
let removing = $state<ModerationCaseRelationship | undefined>();
$effect(() => {
void caseId;
open = false;
});
function label(relationship: ModerationCaseRelationship) {
if (relationship.direction === 'incoming') {
if (relationship.relation === 'appeal_of') return 'Appealed by';
if (relationship.relation === 'duplicate_of') return 'Has duplicate';
}
return moderationCaseRelationLabels[relationship.relation];
}
</script>
<section aria-label="Related cases">
<div class="heading">
<h2>Related cases</h2>
{#if permitted}<Button
size="sm"
onclick={() => {
removing = undefined;
open = true;
}}>Link case</Button
>{/if}
</div>
{#if relationships.length}<ul class="records">
{#each relationships as relationship (relationship.id)}<li>
<div class="heading">
<strong>{label(relationship)}</strong><Badge
>{moderationCaseStatusLabels[relationship.case.status]}</Badge
>
</div>
<a href={moderationCaseHref(relationship.case.id)}>{relationship.case.title}</a>
<p class="hint">
{relationship.actor} · <RelativeTime value={relationship.createdAt} />
</p>
{#if permitted}<Button
size="sm"
ariaLabel={`Remove relationship with ${relationship.case.title}`}
onclick={() => {
removing = relationship;
open = true;
}}>Remove link</Button
>{/if}
</li>{/each}
</ul>{:else}<p class="hint">No cases linked yet.</p>{/if}
{#if candidates.length}<details>
<summary>Other cases for this player ({candidates.length})</summary>
<ul class="records">
{#each candidates as candidate}<li>
<a href={moderationCaseHref(candidate.id)}>{candidate.title}</a>
<span class="hint">{moderationCaseStatusLabels[candidate.status]}</span>
{#if candidate.sharedMatch}<Badge variant="warning"
>Shared archived match</Badge
>
<p class="hint">
Review for a possible duplicate or related incident.
</p>{/if}
</li>{/each}
</ul>
</details>{/if}
</section>
{#if permitted && open}<CaseRelationshipDialog
bind:open
{caseId}
{revision}
{candidates}
relationship={removing}
{onsubmitted}
/>{/if}
<style>
section {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
margin: 0;
line-height: var(--leading-body);
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.records {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-3);
}
.records > li {
display: grid;
justify-items: start;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
details .records {
margin-top: var(--space-3);
}
</style>
@@ -0,0 +1,180 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
let {
open = $bindable(false),
item,
actions,
clearing = false,
onsubmitted
}: {
open?: boolean;
item: ModerationCase;
actions: ModerationCaseAction[];
clearing?: boolean;
onsubmitted: () => void;
} = $props();
const openedRevision = untrack(() => item.revision);
const caseId = untrack(() => item.id);
const mutes = $derived(
actions.filter(
(action) => action.type === 'restriction' && action.kind === 'mute' && action.expiresAt
)
);
let source = $state(untrack(() => item.reviewSanctionId ?? 'custom'));
function browserDate(iso?: string | null): string {
if (!iso) return '';
const date = new Date(iso);
if (!Number.isFinite(date.getTime())) return '';
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
let date = $state(untrack(() => browserDate(item.reviewAt)));
let reason = $state(untrack(() => (clearing ? '' : (item.reviewReason ?? ''))));
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending) {
const chosen = new Date(date);
if (
!reason.trim() ||
(!clearing && source === 'custom' && (!date || !Number.isFinite(chosen.getTime())))
) {
error = 'Choose a review date and record a reason for this follow-up change.';
return;
}
if (!clearing && source !== 'custom' && !mutes.some((mute) => mute.id === source)) {
error = 'Choose an available linked mute or a custom review date.';
return;
}
pending = {
op: 'schedule-case-review',
id: caseId,
revision: openedRevision,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID(),
...(clearing
? { reviewAt: null }
: source === 'custom'
? { reviewAt: chosen.toISOString() }
: { restrictionId: source })
};
}
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
throw new Error(result?.error || 'Could not update the follow-up review.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not update the follow-up review.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={clearing ? 'Complete or clear review' : 'Schedule follow-up review'}
{busy}
description={clearing
? 'Record the review result or why the follow-up is no longer needed. This clears its due date.'
: 'Follow-up reviews remain scheduled when a case is closed.'}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
{#if !clearing}
<Field label="Review timing"
><Select
bind:value={source}
options={[
{ value: 'custom', label: 'Choose a date and time' },
...mutes.map((mute) => ({
value: mute.id,
label: `After mute expires · ${$timeFormat.dateTime(mute.expiresAt!)} · ${mute.reason}`
}))
]}
/></Field
>
{#if source === 'custom'}<Field label="Review date (browser local time)" required
><Input type="datetime-local" bind:value={date} required /></Field
>
{:else}<p class="hint">
The review will be due at the linked mute's recorded expiration time.
</p>{/if}
{/if}
<Field
label={clearing ? 'Review result or clearing reason' : 'Follow-up reason'}
required><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same request safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}
>{uncertain
? 'Retry same request'
: clearing
? 'Complete or clear review'
: 'Save follow-up'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
padding: 0;
margin: 0;
border: 0;
}
.hint {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
</style>
@@ -0,0 +1,131 @@
<script lang="ts">
import type { ModerationCaseNotification } from '$lib/moderation.js';
import Button from '../primitives/Button.svelte';
import Badge from '../primitives/Badge.svelte';
import RelativeTime from '../RelativeTime.svelte';
let {
caseId,
notifications = [],
unread = 0,
permitted,
onsubmitted
}: {
caseId: string;
notifications?: ModerationCaseNotification[];
unread?: number;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let busy = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = $state(null);
const throughId = $derived(Math.max(0, ...notifications.map((item) => item.id)));
$effect(() => {
void caseId;
pending = null;
error = '';
});
async function markSeen() {
if (busy || !permitted || (!pending && !throughId)) return;
pending ??= {
op: 'read-case-updates',
id: caseId,
throughId,
idempotencyKey: crypto.randomUUID()
};
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
if (response.status < 500 && !response.ok) pending = null;
throw new Error(result?.error || 'Could not mark these updates as seen.');
}
pending = null;
onsubmitted();
} catch (cause) {
error =
cause instanceof Error ? cause.message : 'Could not mark these updates as seen.';
} finally {
busy = false;
}
}
</script>
<section aria-label="Your case updates">
<div class="heading">
<h2>Your updates</h2>
{#if unread}<Badge variant="warning">{unread} unread</Badge>{/if}
</div>
{#if notifications.length}<ol>
{#each notifications as update (update.id)}<li>
<div class="heading">
<strong
>{update.kind === 'evidence'
? 'Evidence or note added'
: 'Assignment changed'}</strong
>{#if !update.readAt}<Badge>Unread</Badge>{/if}
</div>
<RelativeTime value={update.createdAt} />
</li>{/each}
</ol>{:else}<p class="hint">No personal case updates.</p>{/if}
{#if notifications.length >= 25}<p class="hint">
Showing your latest 25 updates for this case.
</p>{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if permitted && (unread || pending)}<p class="hint">
Marks your updates through the newest shown entry as read.
</p>
<Button size="sm" disabled={!throughId} loading={busy} onclick={markSeen}
>{pending && error ? 'Retry same request' : 'Mark updates as seen'}</Button
>{/if}
</section>
<style>
section {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
ol {
list-style: none;
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
}
li {
display: grid;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
.hint {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
</style>
@@ -0,0 +1,108 @@
<script lang="ts">
import type { ContextualChatEntry } from '$lib/game-chat.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
let {
entries,
selectedMessageIds = [],
highlightedMessageIds = []
}: {
entries: ContextualChatEntry[];
selectedMessageIds?: string[];
highlightedMessageIds?: string[];
} = $props();
</script>
<p class="excerpt-note">
Selected messages and surrounding context in archive order. Messages between separate selections
may be omitted.
</p>
<ol class="excerpt" aria-label="Preserved conversation excerpt">
{#each entries as entry}
{#if entry.type === 'message'}
{@const highlighted =
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
<li class:highlighted>
<div class="author">
<strong>{entry.message.player}</strong>
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if}
{#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if}
</div>
<div class="metadata">
<span>GUID {entry.message.guid}</span>
{#if entry.context.realTime}<span
>Estimated time <time datetime={entry.context.realTime}
>{$timeFormat.dateTime(entry.context.realTime)}</time
></span
>{:else}<span>Message time unknown</span>{/if}
<span>Log {entry.message.time}</span>
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
</div>
<p>{entry.message.message}</p>
{#if entry.messageId}<details>
<summary>Message identifier</summary><code>{entry.messageId}</code>
</details>{/if}
</li>
{/if}
{/each}
</ol>
<style>
.excerpt {
display: grid;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
}
li {
padding: var(--space-3);
border-left: 3px solid var(--border-subtle);
background: var(--surface-sunken);
min-width: 0;
}
li.highlighted {
border-left-color: var(--state-warning);
background: var(--surface-raised);
}
.author,
.metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
overflow-wrap: anywhere;
}
.author > span,
.metadata {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.metadata {
margin-top: var(--space-1);
}
p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: var(--space-2) 0 0;
line-height: var(--leading-body);
}
details {
margin-top: var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
}
.excerpt-note {
margin: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { ModerationGame, ModerationOverview } from '$lib/moderation.js';
import { moderationCaseStatusLabels } from '$lib/moderation.js';
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
let { game, guid }: { game: ModerationGame; guid: string } = $props();
let report = $state<ModerationOverview | null>(null);
let loading = $state(false);
let failed = $state(false);
let retry = $state(0);
$effect(() => {
const target = guid.trim();
const targetGame = game;
void retry;
const controller = new AbortController();
report = null;
failed = false;
loading = !!target;
const timer = setTimeout(async () => {
if (!target) return;
try {
const params = new URLSearchParams({
view: 'cases',
game: targetGame,
guid: target
});
const response = await fetch(`/api/moderation?${params}`, {
signal: controller.signal
});
if (!response.ok) throw new Error('Case lookup failed');
const result: ModerationOverview = await response.json();
if (!Array.isArray(result.cases) || !Number.isSafeInteger(result.total))
throw new Error('Invalid case lookup response');
if (!controller.signal.aborted) report = result;
} catch {
if (!controller.signal.aborted) failed = true;
} finally {
if (!controller.signal.aborted) loading = false;
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
});
</script>
<aside aria-label="Existing cases for this player">
{#if loading}<p role="status">Checking existing cases for this player…</p>
{:else if failed}<p role="status">
Existing cases could not be loaded. You can still open a case. <button
type="button"
onclick={() => retry++}>Retry lookup</button
>
</p>
{:else if report?.cases.length}
<strong
>{report.total} existing {report.total === 1 ? 'case' : 'cases'} for this player</strong
>
<p>Review these before opening another case.</p>
<ul>
{#each report.cases as item}<li>
<a href={moderationCaseHref(item.id)} target="_blank" rel="noreferrer"
>{item.title}</a
>
· {moderationCaseStatusLabels[item.status]}
</li>{/each}
</ul>
<a href={`${moderationHref({ game, guid })}&view=cases`} target="_blank" rel="noreferrer"
>View all cases for this player</a
>
{:else if report}<p>No existing cases found for this player.</p>{/if}
</aside>
<style>
aside {
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: var(--text-sm);
}
p {
margin: 0;
line-height: var(--leading-body);
}
ul {
margin: var(--space-2) 0;
padding-left: var(--space-5);
max-height: 200px;
overflow: auto;
}
li {
padding: var(--space-1) 0;
overflow-wrap: anywhere;
}
button {
border: 0;
background: none;
padding: 0;
color: var(--accent-primary);
font: inherit;
cursor: pointer;
text-decoration: underline;
}
</style>
@@ -0,0 +1,161 @@
<script lang="ts">
import type { ModerationCaseAction } from '$lib/moderation.js';
import { moderationActionName } from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
import StatusBadge from '../primitives/StatusBadge.svelte';
let {
open = $bindable(false),
caseId,
revision,
actions,
onsubmitted
}: {
open?: boolean;
caseId: string;
revision: number;
actions: ModerationCaseAction[];
onsubmitted: () => void;
} = $props();
let selected = $state('');
let reason = $state('');
let busy = $state(false);
let error = $state('');
let uncertain = $state(false);
let pending: Record<string, unknown> | null = null;
const action = $derived(actions.find((item) => `${item.type}:${item.id}` === selected));
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending) {
if (!action || !reason.trim()) {
error = 'Select an existing action and explain its connection to this case.';
return;
}
pending = {
op: 'link-case-action',
id: caseId,
revision,
actionType: action.type,
actionId: action.id,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
throw new Error(result?.error || 'Could not link the existing action.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error = cause instanceof Error ? cause.message : 'Could not link the existing action.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title="Link existing action"
description="Connect a previously recorded action to this case. Linking preserves its original date and outcome without issuing it again."
{busy}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field label="Existing action" required>
<Select
bind:value={selected}
required
options={[
{ value: '', label: 'Select an action' },
...actions.map((item) => ({
value: `${item.type}:${item.id}`,
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
}))
]}
/>
</Field>
{#if action}<div class="action-detail">
<strong>{moderationActionName(action.kind)}</strong>
<StatusBadge state={`moderation-${action.state}`} />
<p>{action.reason}</p>
<p class="hint">
Recorded by {action.actor} · {$timeFormat.dateTime(action.createdAt)}
</p>
{#if action.type === 'restriction'}<p class="hint">
{action.expiresAt
? `Expires ${$timeFormat.dateTime(action.expiresAt)}`
: 'Permanent restriction'}
</p>{/if}
</div>{/if}
<Field label="Link reason" required hint="Explain how this action relates to the case."
><Textarea bind:value={reason} rows={3} maxlength={4000} required /></Field
>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same request safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}>{uncertain ? 'Retry same request' : 'Link action'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
.action-detail {
padding: var(--space-3);
background: var(--surface-sunken);
border-radius: var(--radius-md);
}
.action-detail p {
margin: var(--space-2) 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,305 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ContextualChatEntry } from '$lib/game-chat.js';
import {
moderationGuid,
moderationCaseStatusLabels,
type ModerationCase,
type ModerationGame,
type ModerationOverview
} from '$lib/moderation.js';
import { conversationExcerpt } from '$ui/moderation-evidence.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
import EvidenceMessages from './EvidenceMessages.svelte';
let {
open = $bindable(false),
game,
matchId,
entries,
selectedMessageIds,
onsubmitted
}: {
open?: boolean;
game: ModerationGame;
matchId: number;
entries: ContextualChatEntry[];
selectedMessageIds: string[];
onsubmitted: (id: string) => void;
} = $props();
const selected = $derived(
entries.filter(
(entry) =>
entry.type === 'message' &&
!!entry.messageId &&
selectedMessageIds.includes(entry.messageId)
)
);
const participants = $derived.by(() => {
const players = new Map<string, string>();
for (const entry of selected) {
if (entry.type === 'message')
players.set(moderationGuid(game, entry.message.guid), entry.message.player);
}
return [...players].map(([guid, name]) => ({
value: guid,
label: `${name} · GUID ${guid}`
}));
});
let guid = $state(untrack(() => participants[0]?.value ?? ''));
let destination = $state('');
let title = $state('');
let note = $state('');
let contextBefore = $state<number | undefined>(3);
let contextAfter = $state<number | undefined>(3);
let cases = $state<ModerationCase[]>([]);
let loading = $state(false);
let loadError = $state('');
let reload = $state(0);
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
const excerpt = $derived(
conversationExcerpt(entries, selectedMessageIds, contextBefore ?? 0, contextAfter ?? 0)
);
const highlightedMessageIds = $derived(
selected.flatMap((entry) =>
entry.type === 'message' &&
entry.messageId &&
moderationGuid(game, entry.message.guid) === guid
? [entry.messageId]
: []
)
);
$effect(() => {
const selectedGuid = guid;
const selectedGame = game;
void reload;
const controller = new AbortController();
cases = [];
destination = '';
loadError = '';
loading = true;
async function loadCases() {
try {
const loaded: ModerationCase[] = [];
let offset = 0;
while (true) {
const params = new URLSearchParams({
view: 'cases',
game: selectedGame,
guid: selectedGuid,
offset: String(offset)
});
const response = await fetch(`/api/moderation?${params}`, {
signal: controller.signal
});
if (!response.ok)
throw new Error(
'Could not load existing cases. Retry before attaching evidence.'
);
const report: ModerationOverview = await response.json();
if (controller.signal.aborted) return;
loaded.push(...report.cases);
offset += report.cases.length;
if (!report.cases.length || offset >= report.total) break;
}
cases = loaded;
} catch (cause) {
if (!controller.signal.aborted)
loadError =
cause instanceof Error ? cause.message : 'Could not load existing cases.';
} finally {
if (!controller.signal.aborted) loading = false;
}
}
void loadCases();
return () => controller.abort();
});
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || (!pending && (loading || loadError))) return;
if (!pending) {
const target = cases.find((item) => item.id === destination);
if (!guid || !highlightedMessageIds.length || (destination && !target)) {
error = 'Choose a case subject with at least one selected message.';
return;
}
if (
![contextBefore, contextAfter].every(
(value) => Number.isInteger(value) && value! >= 0 && value! <= 20
)
) {
error = 'Choose between 0 and 20 surrounding messages on each side.';
return;
}
pending = {
...(target
? { op: 'attach-evidence', id: target.id, revision: target.revision }
: { op: 'create-case', game, guid, title: title.trim(), note: note.trim() }),
evidence: { matchId, messageIds: selectedMessageIds, contextBefore, contextAfter },
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
if (response.status === 409) reload++;
throw new Error(result?.error || 'Could not attach the selected evidence.');
}
open = false;
onsubmitted(result.id);
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not attach the selected evidence.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title="Attach to case"
{busy}
wide
description={`${selectedMessageIds.length} selected messages from match #${matchId}`}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field
label="Case subject"
required
hint="Select the player being reviewed. Other participants remain in the preserved conversation."
>
<Select bind:value={guid} options={participants} required />
</Field>
{#if loading}<p role="status">Loading this player's cases…</p>
{:else if loadError}<p class="ui-error" role="alert">
{loadError}
<Button size="sm" onclick={() => reload++}>Retry</Button>
</p>
{:else}
<Field
label="Destination"
hint={cases.length
? 'Review existing cases before opening another.'
: 'No existing cases for this player.'}
>
<Select
bind:value={destination}
options={[
{ value: '', label: 'Open a new case' },
...cases.map((item) => ({
value: item.id,
label: `${item.title} · ${moderationCaseStatusLabels[item.status]}`
}))
]}
/>
</Field>
{#if !destination}
<Field label="Case title" required
><Input bind:value={title} maxlength={160} required /></Field
>
<Field label="Private staff note"
><Textarea bind:value={note} rows={3} maxlength={4000} /></Field
>
{/if}
{/if}
<div class="paired">
<Field label="Messages before" hint="Per selected message"
><Input
type="number"
bind:value={contextBefore}
min={0}
max={20}
step={1}
required
/></Field
>
<Field label="Messages after" hint="Per selected message"
><Input
type="number"
bind:value={contextAfter}
min={0}
max={20}
step={1}
required
/></Field
>
</div>
<p class="hint">
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
Messages already preserved as evidence in this case are not attached again.
</p>
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry the same request to avoid duplicate evidence.
</p>{/if}
<div class="actions">
<Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
<Button
type="submit"
variant="primary"
loading={busy}
disabled={!uncertain && (loading || !!loadError)}
>{uncertain
? 'Retry same request'
: destination
? 'Attach evidence'
: 'Open case with evidence'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
.paired {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.hint {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-body);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
}
</style>
@@ -7,9 +7,11 @@
type ModerationCoverage, type ModerationCoverage,
type ModerationGame, type ModerationGame,
type ModerationScope, type ModerationScope,
type ModerationCase type ModerationCase,
type ModerationCaseAction
} from '$lib/moderation.js'; } from '$lib/moderation.js';
import { moderationGameName } from '$ui/moderation.js'; import { moderationActionName, moderationGameName } from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import Dialog from '../primitives/Dialog.svelte'; import Dialog from '../primitives/Dialog.svelte';
@@ -18,6 +20,7 @@
import Select from '../primitives/Select.svelte'; import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte'; import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
import ExistingCaseNotice from './ExistingCaseNotice.svelte';
let { let {
open = $bindable(false), open = $bindable(false),
@@ -26,6 +29,7 @@
admin, admin,
seed = {}, seed = {},
assignees = [], assignees = [],
linkedActions = [],
onsubmitted onsubmitted
}: { }: {
open?: boolean; open?: boolean;
@@ -43,6 +47,7 @@
coverage: ModerationCoverage[]; coverage: ModerationCoverage[];
admin: boolean; admin: boolean;
assignees?: { id: string; username: string }[]; assignees?: { id: string; username: string }[];
linkedActions?: ModerationCaseAction[];
seed?: { seed?: {
game?: ModerationGame; game?: ModerationGame;
guid?: string; guid?: string;
@@ -81,6 +86,21 @@
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length); const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action'); const gameText = $derived(mode === 'restriction' || mode === 'action');
let resolution = $state('resolved'); let resolution = $state('resolved');
let actionReference = $state('');
const referenceActions = $derived(
linkedActions.filter((item) =>
resolution === 'warning_issued'
? item.type === 'action' && item.kind === 'warn' && item.state === 'confirmed'
: resolution === 'restriction_applied' && item.type === 'restriction'
)
);
const referencedAction = $derived(
referenceActions.find((item) => `${item.type}:${item.id}` === actionReference)
);
$effect(() => {
void resolution;
actionReference = '';
});
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let uncertain = $state(false); let uncertain = $state(false);
@@ -203,7 +223,10 @@
id: seed.id, id: seed.id,
revision: seed.revision, revision: seed.revision,
status: resolution, status: resolution,
reason: reason.trim() reason: reason.trim(),
...(referencedAction
? { actionReference: { type: referencedAction.type, id: referencedAction.id } }
: {})
}; };
} }
@@ -373,6 +396,10 @@
complete. complete.
</p>{/if} </p>{/if}
{#if mode === 'case' || mode === 'edit-case'} {#if mode === 'case' || mode === 'edit-case'}
{#if mode === 'case' && open && game && guid.trim()}<ExistingCaseNotice
{game}
{guid}
/>{/if}
<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
> >
@@ -468,6 +495,29 @@
separately, and send warnings through Live action. Choosing an outcome does not separately, and send warnings through Live action. Choosing an outcome does not
change enforcement or send a warning. change enforcement or send a warning.
</p>{/if} </p>{/if}
{#if mode === 'resolve' && ['warning_issued', 'restriction_applied'].includes(resolution)}
<Field
label="Supporting action"
hint="Optional. Reference an existing linked action as the basis for this outcome."
>
<Select
bind:value={actionReference}
options={[
{ value: '', label: 'No action reference' },
...referenceActions.map((item) => ({
value: `${item.type}:${item.id}`,
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
}))
]}
/>
</Field>
{#if !referenceActions.length}<p class="hint">
{resolution === 'warning_issued'
? 'No confirmed warnings are linked to this case.'
: 'No restrictions are linked to this case.'} Use “Link existing action” on
the case to add a prior action.
</p>{/if}
{/if}
{#if mode === 'reopen'}<p class="hint"> {#if mode === 'reopen'}<p class="hint">
Reopening returns the case to Open and preserves its evidence, restrictions, and Reopening returns the case to Open and preserves its evidence, restrictions, and
earlier decisions. earlier decisions.
+55
View File
@@ -0,0 +1,55 @@
import type { ContextualChatEntry } from '$lib/game-chat.js';
/** Select surrounding messages by archive order, preserving each message once. */
export function conversationExcerpt(
entries: readonly ContextualChatEntry[],
selectedMessageIds: readonly string[],
contextBefore = 3,
contextAfter = 3
): ContextualChatEntry[] {
const messages = entries.filter((entry) => entry.type === 'message');
const selected = new Set(selectedMessageIds);
const included = new Set<number>();
messages.forEach((entry, index) => {
if (!entry.messageId || !selected.has(entry.messageId)) return;
for (
let i = Math.max(0, index - contextBefore);
i <= Math.min(messages.length - 1, index + contextAfter);
i++
)
included.add(i);
});
return messages.filter((_, index) => included.has(index));
}
/** Old single-message snapshots remain readable alongside conversation excerpts. */
export function preservedConversation(value: unknown): {
entries: ContextualChatEntry[];
selectedMessageIds: string[];
highlightedMessageIds: string[];
} | null {
if (!value || typeof value !== 'object') return null;
const snapshot = value as Record<string, unknown>;
if (!Array.isArray(snapshot.entries)) return null;
const entries = snapshot.entries.filter(
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
return (
typeof entry.message?.player === 'string' &&
typeof entry.message?.guid === 'string' &&
typeof entry.message?.message === 'string' &&
typeof entry.message?.time === 'string' &&
entry.context &&
typeof entry.context === 'object'
);
}
);
if (!entries.length) return null;
const strings = (input: unknown): string[] =>
Array.isArray(input) ? input.filter((id): id is string => typeof id === 'string') : [];
return {
entries,
selectedMessageIds: strings(snapshot.selectedMessageIds),
highlightedMessageIds: strings(snapshot.highlightedMessageIds)
};
}
+49
View File
@@ -1,5 +1,7 @@
import { import {
moderationCaseStatusLabels, moderationCaseStatusLabels,
moderationCaseRelationLabels,
type ModerationCaseRelation,
type ModerationCaseStatus, type ModerationCaseStatus,
type ModerationGame type ModerationGame
} from '$lib/moderation.js'; } from '$lib/moderation.js';
@@ -68,12 +70,28 @@ export const moderationActionLabels: Record<string, string> = {
'case.appeal_denied': 'Appeal denied', 'case.appeal_denied': 'Appeal denied',
'case.reopened': 'Case reopened', 'case.reopened': 'Case reopened',
'case.updated': 'Case updated', 'case.updated': 'Case updated',
'case.assessed': 'Assessment revised',
'case.review_scheduled': 'Follow-up updated',
'schedule-case-review': 'Follow-up updated',
'case.linked': 'Case relationship added',
'case.unlinked': 'Case relationship removed',
'link-case': 'Case relationship added',
'unlink-case': 'Case relationship removed',
'assess-case': 'Assessment revised',
'case.evidence_attached': 'Evidence attached', 'case.evidence_attached': 'Evidence attached',
'case.restriction': 'Restriction linked to case', 'case.restriction': 'Restriction linked to case',
'case.action_linked': 'Existing action linked to case',
'link-case-action': 'Existing action linked to case',
'player.note': 'Private player note', 'player.note': 'Private player note',
'runtime.configure': 'Integration configuration requested' 'runtime.configure': 'Integration configuration requested'
}; };
export function moderationActionName(kind: string): string {
return (
moderationActionLabels[kind] ?? (kind === 'ban' ? 'Ban' : kind === 'mute' ? 'Mute' : kind)
);
}
export function moderationEventDetail(detail: string, action: string): string { export function moderationEventDetail(detail: string, action: string): string {
if (action.startsWith('case.')) { if (action.startsWith('case.')) {
try { try {
@@ -88,6 +106,25 @@ export function moderationEventDetail(detail: string, action: string): string {
? moderationCaseStatusLabels[item as ModerationCaseStatus] ? moderationCaseStatusLabels[item as ModerationCaseStatus]
: item : item
: ''; : '';
if (
(action === 'case.linked' || action === 'case.unlinked') &&
typeof value.relation === 'string' &&
Object.hasOwn(moderationCaseRelationLabels, value.relation) &&
typeof value.relatedCaseId === 'string' &&
typeof value.reason === 'string'
) {
const label =
value.direction === 'incoming' && value.relation !== 'same_incident'
? value.relation === 'appeal_of'
? 'Appealed by'
: 'Has duplicate'
: moderationCaseRelationLabels[value.relation as ModerationCaseRelation];
return `${label}: ${value.relatedCaseId}\n${value.reason}`;
}
if (action === 'case.review_scheduled' && typeof value.reason === 'string')
return `${typeof value.reviewAt === 'string' ? `Review scheduled for ${value.reviewAt}` : 'Follow-up cleared'}\n${value.reason}`;
if (action === 'case.assessed' && typeof value.summary === 'string')
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
if (action === 'case.updated') { if (action === 'case.updated') {
const changes = ['title', 'priority', 'assignee', 'status'].flatMap((key) => { const changes = ['title', 'priority', 'assignee', 'status'].flatMap((key) => {
const change = value[key]; const change = value[key];
@@ -110,6 +147,18 @@ export function moderationEventDetail(detail: string, action: string): string {
return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`; return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`;
if (action === 'case.restriction' && typeof value.reason === 'string') if (action === 'case.restriction' && typeof value.reason === 'string')
return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`; return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`;
if (
typeof value.reason === 'string' &&
value.actionReference &&
typeof value.actionReference === 'object'
) {
const reference = value.actionReference as { type?: unknown; id?: unknown };
if (
(reference.type === 'restriction' || reference.type === 'action') &&
typeof reference.id === 'string'
)
return `${value.reason}\nReferenced ${reference.type === 'restriction' ? 'restriction' : 'action'}: ${reference.id}`;
}
} catch { } catch {
// Older case events and decision reasons are plain staff-authored text. // Older case events and decision reasons are plain staff-authored text.
} }
+15 -2
View File
@@ -1,8 +1,13 @@
import { readMatchQuery } from '$lib/match-history-query.js'; import { readMatchQuery } from '$lib/match-history-query.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
import { authError } from '$lib/server/auth/http.js';
export function GET({ url }: { url: URL }) { export function GET({ url, locals }: { url: URL; locals?: MatchArchiveLocals }) {
const limit = Number(url.searchParams.get('limit') ?? 25); const limit = Number(url.searchParams.get('limit') ?? 25);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 }); return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
@@ -13,5 +18,13 @@ export function GET({ url }: { url: URL }) {
} catch { } catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 }); return json({ error: 'Invalid match history filters.' }, { status: 400 });
} }
return json(serverMatchHistory(undefined, limit, query)); try {
if (query.archive) requireMatchArchiveAccess(locals?.user);
return json(
serverMatchHistory(undefined, limit, query),
query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined
);
} catch (cause) {
return authError(cause);
}
} }
+1 -1
View File
@@ -19,7 +19,7 @@ export const GET: RequestHandler = ({ locals, url }) => {
export const POST: RequestHandler = async ({ locals, request }) => { export const POST: RequestHandler = async ({ locals, request }) => {
try { try {
const actor = requireUser(locals.user); const actor = requireUser(locals.user);
const body = await authBody(request, 16 * 1024); const body = await authBody(request, 64 * 1024);
if (body.op === 'configure') if (body.op === 'configure')
return json( return json(
moderationMutation(actor.id, body, () => moderationMutation(actor.id, body, () =>
@@ -1,8 +1,21 @@
import { readMatchQuery } from '$lib/match-history-query.js'; import { readMatchQuery } from '$lib/match-history-query.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
import { authError } from '$lib/server/auth/http.js';
export function GET({ params, url }: { params: { name: string }; url: URL }) { export function GET({
params,
url,
locals
}: {
params: { name: string };
url: URL;
locals?: MatchArchiveLocals;
}) {
const limit = Number(url.searchParams.get('limit') ?? 25); const limit = Number(url.searchParams.get('limit') ?? 25);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 }); return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
@@ -13,5 +26,13 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) {
} catch { } catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 }); return json({ error: 'Invalid match history filters.' }, { status: 400 });
} }
return json(serverMatchHistory(params.name, limit, query)); try {
if (query.archive) requireMatchArchiveAccess(locals?.user);
return json(
serverMatchHistory(params.name, limit, query),
query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined
);
} catch (cause) {
return authError(cause);
}
} }
+17 -1
View File
@@ -1,12 +1,28 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.js'; import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.js';
export function load({ url }: { url: URL }) { import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({
url,
locals,
setHeaders
}: {
url: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query; let query;
try { try {
query = readMatchQuery(matchApiParams(url)); query = readMatchQuery(matchApiParams(url));
} catch { } catch {
error(400, 'Invalid match history filters.'); error(400, 'Invalid match history filters.');
} }
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
return { history: serverMatchHistory(undefined, matchPageSize, query) }; return { history: serverMatchHistory(undefined, matchPageSize, query) };
} }
+12 -2
View File
@@ -2,12 +2,22 @@
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
import MatchHistory from '$ui/components/MatchHistory.svelte'; import MatchHistory from '$ui/components/MatchHistory.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import { page } from '$app/stores';
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
</script> </script>
<svelte:head><title>Matches · Electric Cherry</title></svelte:head> <svelte:head
><title>{archive ? 'Moderation match archive' : 'Matches'} · Electric Cherry</title
></svelte:head
>
<div class="page"> <div class="page">
<SectionHeader title="Matches" subtitle="Recorded matches across your servers." /> <SectionHeader
title={archive ? 'Moderation match archive' : 'Matches'}
subtitle={archive
? 'Retained match reports and chat for evidence review.'
: 'Recorded matches across your servers.'}
/>
<MatchHistory initial={data.history} /> <MatchHistory initial={data.history} />
</div> </div>
+27 -2
View File
@@ -1,9 +1,34 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getMatchDetail } from '$lib/server/fleet/match-history.js'; import { getMatchDetail } from '$lib/server/fleet/match-history.js';
import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({ params }: { params: { id: string } }) { export function load({
params,
url,
locals,
setHeaders
}: {
params: { id: string };
url?: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query;
try {
query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams());
} catch {
error(400, 'Invalid match history filters.');
}
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found'); if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found');
const match = getMatchDetail(Number(params.id)); const match = getMatchDetail(Number(params.id), query.archive);
if (!match) error(404, 'Match not found'); if (!match) error(404, 'Match not found');
return { match }; return { match };
} }
+77 -22
View File
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { browser } from '$app/environment';
import { timestampISO } from '$lib/time-zone.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { uiNow } from '$ui/stores/servers.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import type { MatchDetail } from '$lib/match-history.js'; import type { MatchDetail } from '$lib/match-history.js';
@@ -16,6 +20,27 @@
let { data }: { data: { match: MatchDetail } } = $props(); let { data }: { data: { match: MatchDetail } } = $props();
const match = $derived(data.match); const match = $derived(data.match);
const completed = $derived(match.status === 'completed');
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
const reporting = $derived(
match.status === 'active' &&
!match.server_deleted_at &&
freshStatusTimestamp(
timestampISO(match.lastReportedAt) ?? null,
browser ? $uiNow : Date.now()
)
);
const status = $derived(
completed
? 'Completed'
: match.status === 'active'
? reporting
? 'In progress'
: 'Not reporting'
: match.status === 'end-report-missing'
? 'End report missing'
: 'Interrupted'
);
const gameName = $derived( const gameName = $derived(
match.game === 't5' match.game === 't5'
? 'Black Ops I' ? 'Black Ops I'
@@ -45,22 +70,27 @@
<SectionHeader <SectionHeader
title={match.map} title={match.map}
eyebrow={`Match #${match.id}`} eyebrow={`Match #${match.id}`}
subtitle={`${gameName} · Recorded match results`} subtitle={`${gameName} · ${completed ? 'Recorded match results' : 'Recorded match archive'}`}
> >
{#snippet crumbs()}<Breadcrumbs {#snippet crumbs()}<Breadcrumbs
items={[ items={[
{ label: 'Match History', href: '/matches' }, {
label: archive ? 'Moderation archive' : 'Match History',
href: archive ? '/matches?matchArchive=1' : '/matches'
},
{ label: `Match #${match.id}` } { label: `Match #${match.id}` }
]} ]}
/>{/snippet} />{/snippet}
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet} {#snippet actions()}<Badge variant={completed || reporting ? 'online' : 'warning'} size="md"
>{status}</Badge
>{/snippet}
</SectionHeader> </SectionHeader>
<section class="summary" aria-label="Match summary"> <section class="summary" class:unfinished={!completed} aria-label="Match summary">
<Card <Card
><Stat ><Stat
label="Final round" label={completed ? 'Final round' : 'Highest observed round'}
value={match.finalRound} value={completed ? match.finalRound : match.observedRound}
tone="accent" tone="accent"
size="lg" size="lg"
tween={false} tween={false}
@@ -68,7 +98,7 @@
> >
<Card <Card
><Stat ><Stat
label="Game time" label={completed ? 'Game time' : 'Recorded duration'}
value={match.durationMs === null ? null : formatDuration(match.durationMs)} value={match.durationMs === null ? null : formatDuration(match.durationMs)}
size="lg" size="lg"
tween={false} tween={false}
@@ -82,8 +112,14 @@
tween={false} tween={false}
/></Card /></Card
> >
<Card><Stat label="Players at end" value={match.endPlayers} size="lg" tween={false} /></Card {#if completed}<Card
> ><Stat
label="Players at end"
value={match.endPlayers}
size="lg"
tween={false}
/></Card
>{/if}
</section> </section>
<Card padding="lg"> <Card padding="lg">
@@ -104,9 +140,12 @@
<dd><RelativeTime value={match.startedAt} /></dd> <dd><RelativeTime value={match.startedAt} /></dd>
</div> </div>
<div> <div>
<dt>Ended</dt> <dt>{completed ? 'Ended' : 'Last reported'}</dt>
<dd> <dd>
{#if match.endedAt}<RelativeTime value={match.endedAt} />{:else}—{/if} <RelativeTime
value={completed ? match.endedAt : match.lastReportedAt}
missing="Not recorded"
/>
</dd> </dd>
</div> </div>
<div> <div>
@@ -119,13 +158,19 @@
game time is unknown. game time is unknown.
</p>{/if} </p>{/if}
</Card> </Card>
{#if !completed}<p class="note">
This match has no completed results. The archive preserves observed participants,
reported stats, and retained chat for review.
</p>{/if}
<section class="results" aria-labelledby="players-title"> <section class="results" aria-labelledby="players-title">
<div class="results-heading"> <div class="results-heading">
<h2 id="players-title">Player results</h2> <h2 id="players-title">{completed ? 'Player results' : 'Recorded participants'}</h2>
<p> <p>
Every recorded player, including those absent from the final report. Stats are the {completed
highest reported counters for this match. ? 'Every recorded player, including those absent from the final report.'
: 'Every participant recorded before reporting stopped or in the latest available reports.'}
Stats are the highest reported counters for this match.
{#if hasScoreTotals} {#if hasScoreTotals}
Total score is the highest recorded cumulative points earned in this match. Total score is the highest recorded cumulative points earned in this match.
{:else} {:else}
@@ -163,8 +208,13 @@
<small class="guid">{player.guid}</small> <small class="guid">{player.guid}</small>
</th> </th>
<td class="participation" <td class="participation"
><Badge variant={player.presentAtEnd ? 'online' : 'neutral'} ><Badge
>{player.presentAtEnd variant={completed && player.presentAtEnd
? 'online'
: 'neutral'}
>{!completed
? 'Recorded participant'
: player.presentAtEnd
? 'At match end' ? 'At match end'
: 'Not at match end'}</Badge : 'Not at match end'}</Badge
>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td >{#if !player.hasStats}<small>No stats recorded</small>{/if}</td
@@ -191,8 +241,8 @@
{#if hasScoreTotals} {#if hasScoreTotals}
<p> <p>
Total score is the highest recorded cumulative points earned in this match. Players Total score is the highest recorded cumulative points earned in this match. Players
absent from the final report retain their latest recorded total. A dash in Total {completed ? 'absent from the final report' : 'absent from later reports'} retain their
score means no total was recorded for that player. latest recorded total. A dash in Total score means no total was recorded for that player.
</p> </p>
{:else} {:else}
<p> <p>
@@ -203,9 +253,10 @@
{/if} {/if}
<p> <p>
Highest round includes late joins. Rounds survived counts only observed round Highest round includes late joins. Rounds survived counts only observed round
progression while the player was present. “At match end” means the player appeared in progression while the player was present. {#if completed}“At match end” means the player
the final report; other players keep their earlier stats. A dash means no value was appeared in the final report; other players keep their earlier stats.{:else}Participation
recorded. confirms a recorded report and does not establish who was present at the end.{/if} A dash
means no value was recorded.
</p> </p>
<p> <p>
Player names use the latest recorded identity. Times reflect when reports were received Player names use the latest recorded identity. Times reflect when reports were received
@@ -240,6 +291,9 @@
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4); gap: var(--space-4);
} }
.summary.unfinished {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.details { .details {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -341,7 +395,8 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.summary { .summary,
.summary.unfinished {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.details { .details {
+97 -1
View File
@@ -7,6 +7,8 @@
canRevokeModerationRestriction, canRevokeModerationRestriction,
moderationCaseStatuses, moderationCaseStatuses,
moderationCaseStatusLabels, moderationCaseStatusLabels,
moderationConcernLabels,
moderationFindingLabels,
type ModerationOverview, type ModerationOverview,
type ModerationQuery, type ModerationQuery,
type ModerationGame type ModerationGame
@@ -29,6 +31,7 @@
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import Freshness from '$ui/components/Freshness.svelte'; import Freshness from '$ui/components/Freshness.svelte';
@@ -246,6 +249,10 @@
error={refreshError ? 'Updates paused' : ''} error={refreshError ? 'Updates paused' : ''}
/> />
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button> <Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
<Button href="/matches?matchArchive=1">Chat archive</Button>
{#if report.unreadCount}<Button href="/moderation?view=cases&review=updates"
>Your case updates ({report.unreadCount})</Button
>{/if}
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')} {#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
>Restrict player</Button >Restrict player</Button
><Button onclick={() => openDialog('action')}>Live action</Button>{/if} ><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
@@ -336,6 +343,7 @@
caseStatus: filters.caseStatus ?? null, caseStatus: filters.caseStatus ?? null,
priority: filters.priority ?? null, priority: filters.priority ?? null,
assignedTo: filters.assignedTo ?? null, assignedTo: filters.assignedTo ?? null,
review: filters.review ?? null,
offset: null offset: null
}); });
}} }}
@@ -361,6 +369,18 @@
> >
<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'} {#if report.query.view === 'cases'}
<Field label="Review queue"
><Select
name="review"
bind:value={filters.review}
options={[
{ value: '', label: 'All cases' },
{ value: 'due', label: 'Overdue reviews' },
{ value: 'scheduled', label: 'Scheduled reviews' },
{ value: 'updates', label: 'Your unread updates' }
]}
/></Field
>
<Field label="Case status" <Field label="Case status"
><Select ><Select
name="caseStatus" name="caseStatus"
@@ -415,7 +435,8 @@
matchId: null, matchId: null,
caseStatus: null, caseStatus: null,
priority: null, priority: null,
assignedTo: null assignedTo: null,
review: null
})}>Reset</Button })}>Reset</Button
> >
</form> </form>
@@ -594,6 +615,31 @@
description="Restrictions matching these filters will appear here, including expiry and revocation history." description="Restrictions matching these filters will appear here, including expiry and revocation history."
/>{/if} />{/if}
{:else if report.query.view === 'cases'} {:else if report.query.view === 'cases'}
{#if report.notifications?.length}
<section class="record" aria-label="Your case notifications">
<h2>Your case updates ({report.unreadCount ?? report.notifications.length})</h2>
<ul class="notification-list">
{#each report.notifications as notification (notification.id)}
<li>
<a href={moderationCaseHref(notification.caseId)}
>{notification.caseTitle}</a
>
· {notification.kind === 'evidence'
? 'Evidence or note added'
: 'Assignment changed'} · <RelativeTime
value={notification.createdAt}
/>
</li>
{/each}
</ul>
<p class="hint">Open a case to review and mark its updates read.</p>
</section>
{/if}
<div class="view-actions">
<Button href="/moderation?view=cases&review=due">Overdue reviews</Button><Button
href="/moderation?view=cases&review=updates">Your unread updates</Button
>
</div>
{#if permitted}<div class="view-actions"> {#if permitted}<div class="view-actions">
<Button onclick={() => openDialog('case')}>Open case</Button> <Button onclick={() => openDialog('case')}>Open case</Button>
</div>{/if} </div>{/if}
@@ -609,6 +655,43 @@
> >
· {moderationGameName(item.game)} · {moderationGameName(item.game)}
</p> </p>
{#if item.assessment}
<p class="assessment-summary">{item.assessment.summary}</p>
<div class="record-heading" aria-label="Assessment findings">
{#each item.assessment.findings as finding}
<Badge
variant={finding.finding === 'substantiated'
? 'warning'
: 'neutral'}
>
{moderationConcernLabels[finding.category]} · {moderationFindingLabels[
finding.finding
]}
</Badge>
{/each}
</div>
{:else}<p class="hint">Not assessed</p>{/if}
{#if item.reviewAt}<div class="record-heading">
<Badge
variant={Date.parse(item.reviewAt) <=
Date.parse(report.checkedAt)
? 'warning'
: 'info'}
>{Date.parse(item.reviewAt) <= Date.parse(report.checkedAt)
? 'Overdue review'
: 'Review scheduled'}</Badge
>
<time datetime={item.reviewAt}
>{$timeFormat.dateTime(item.reviewAt)}</time
>
</div>{/if}
{#if item.unreadUpdates}<p>
<Badge variant="info"
>{item.unreadUpdates} unread {item.unreadUpdates === 1
? 'update'
: 'updates'}</Badge
>
</p>{/if}
<dl> <dl>
<div> <div>
<dt>Priority</dt> <dt>Priority</dt>
@@ -874,4 +957,17 @@
flex: 1 1 100%; flex: 1 1 100%;
} }
} }
.assessment-summary {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
.notification-list {
margin: 0;
padding-left: var(--space-5);
display: grid;
gap: var(--space-2);
}
</style> </style>
+120 -8
View File
@@ -12,6 +12,7 @@
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { import {
moderationActionLabels, moderationActionLabels,
moderationActionName,
moderationEventDetail, moderationEventDetail,
moderationGameName, moderationGameName,
moderationHref moderationHref
@@ -28,6 +29,13 @@
import CopyValue from '$ui/components/CopyValue.svelte'; import CopyValue from '$ui/components/CopyValue.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte'; import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte'; import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte';
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
import CaseAssessment from '$ui/components/moderation/CaseAssessment.svelte';
import CaseRelationships from '$ui/components/moderation/CaseRelationships.svelte';
import CaseFollowUp from '$ui/components/moderation/CaseFollowUp.svelte';
import CaseUpdates from '$ui/components/moderation/CaseUpdates.svelte';
import { preservedConversation } from '$ui/moderation-evidence.js';
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props(); let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
let snapshot = $state<ModerationCaseDetail | null>(null); let snapshot = $state<ModerationCaseDetail | null>(null);
@@ -53,6 +61,7 @@
let generation = 0; let generation = 0;
let controller: AbortController | undefined; let controller: AbortController | undefined;
let dialogOpen = $state(false); let dialogOpen = $state(false);
let linkActionOpen = $state(false);
let dialogKey = $state(0); let dialogKey = $state(0);
let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note'); let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note');
let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({}); let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({});
@@ -65,6 +74,7 @@
refreshError = ''; refreshError = '';
notice = ''; notice = '';
dialogOpen = false; dialogOpen = false;
linkActionOpen = false;
updatedAt = Date.parse(initial.checkedAt); updatedAt = Date.parse(initial.checkedAt);
}); });
}); });
@@ -147,6 +157,17 @@
cancelRefresh(); cancelRefresh();
void refresh(); void refresh();
} }
function actionLinked() {
notice =
'Existing action linked to this case. Its original date and outcome are preserved.';
cancelRefresh();
void refresh();
}
function assessmentSaved() {
notice = 'Case assessment saved. The earlier assessment remains in revision history.';
cancelRefresh();
void refresh();
}
function archiveContext(raw: string | null | undefined) { function archiveContext(raw: string | null | undefined) {
if (!raw) return null; if (!raw) return null;
@@ -179,7 +200,8 @@
round: Number.isInteger(value.entry?.context?.round) round: Number.isInteger(value.entry?.context?.round)
? (value.entry.context.round as number) ? (value.entry.context.round as number)
: null, : null,
original: JSON.stringify(value, null, 2) original: JSON.stringify(value, null, 2),
excerpt: preservedConversation(value)
}; };
} catch { } catch {
return null; return null;
@@ -215,8 +237,8 @@
</SectionHeader> </SectionHeader>
{#if !permitted}<p class="notice"> {#if !permitted}<p class="notice">
Read-only moderation access. You can inspect this case, its evidence, and linked Read-only moderation access. You can inspect this case, its evidence, and linked actions
restrictions. and restrictions.
</p>{/if} </p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if} {#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if refreshError}<div class="ui-error" role="alert"> {#if refreshError}<div class="ui-error" role="alert">
@@ -260,6 +282,14 @@
<div class="workspace"> <div class="workspace">
<div class="case-main"> <div class="case-main">
<CaseAssessment
caseId={item.id}
revision={item.revision}
assessment={report.assessment}
history={report.assessmentHistory}
{permitted}
onsubmitted={assessmentSaved}
/>
<section class="panel" aria-labelledby="evidence-title"> <section class="panel" aria-labelledby="evidence-title">
<div class="section-heading"> <div class="section-heading">
<h2 id="evidence-title"> <h2 id="evidence-title">
@@ -280,12 +310,14 @@
>Round {archive.round}</Badge >Round {archive.round}</Badge
>{/if} >{/if}
</div> </div>
<blockquote>{evidence.text}</blockquote> {#if archive?.excerpt}
<EvidenceMessages {...archive.excerpt} />
{:else}<blockquote>{evidence.text}</blockquote>{/if}
<p class="hint">{evidence.source}</p> <p class="hint">{evidence.source}</p>
{#if archive}<div class="source-context"> {#if archive}<div class="source-context">
{#if archive.matchId}<a {#if archive.matchId}<a
href={contextHref( href={contextHref(
`/matches/${archive.matchId}`, `/matches/${archive.matchId}?matchArchive=1`,
$page.url $page.url
)}>View match #{archive.matchId}</a )}>View match #{archive.matchId}</a
>{/if} >{/if}
@@ -366,6 +398,40 @@
</div> </div>
<aside class="case-sidebar"> <aside class="case-sidebar">
<CaseFollowUp
{item}
actions={report.linkedActions}
checkedAt={report.checkedAt}
{permitted}
onsubmitted={() => {
notice = 'Follow-up review updated.';
cancelRefresh();
void refresh();
}}
/>
<CaseUpdates
caseId={item.id}
notifications={report.notifications}
unread={item.unreadUpdates}
{permitted}
onsubmitted={() => {
cancelRefresh();
void refresh();
}}
/>
<CaseRelationships
caseId={item.id}
revision={item.revision}
relationships={report.relatedCases}
candidates={report.candidateCases}
{permitted}
onsubmitted={() => {
notice =
'Case relationship updated. The change is recorded in both case histories.';
cancelRefresh();
void refresh();
}}
/>
<section class="panel" aria-labelledby="player-title"> <section class="panel" aria-labelledby="player-title">
<h2 id="player-title">Player identity</h2> <h2 id="player-title">Player identity</h2>
<a class="player-name" href={contextHref(playerProfileHref(item.guid), $page.url)} <a class="player-name" href={contextHref(playerProfileHref(item.guid), $page.url)}
@@ -395,6 +461,45 @@
> >
</section> </section>
<section class="panel" aria-labelledby="actions-title">
<div class="section-heading">
<h2 id="actions-title">Linked actions</h2>
{#if permitted}<Button
size="sm"
disabled={!report.availableActions?.length}
onclick={() => (linkActionOpen = true)}>Link existing action</Button
>{/if}
</div>
{#if report.linkedActions?.length}<ol class="records">
{#each report.linkedActions as action (`${action.type}:${action.id}`)}<li
class="record"
>
<div class="row">
<strong>{moderationActionName(action.kind)}</strong><StatusBadge
state={`moderation-${action.state}`}
/>
</div>
<p class="record-text">{action.reason}</p>
<p class="hint">
{action.actor} · <RelativeTime value={action.createdAt} />
</p>
<CopyValue
value={action.id}
label={action.type === 'restriction'
? 'Restriction ID'
: 'Action ID'}
/>
</li>{/each}
</ol>{:else}<p class="hint">
No existing actions are linked to this case.
</p>{/if}
{#if permitted}<p class="hint">
{report.availableActions?.length
? `${report.availableActions.length} previous actions available for this player.`
: 'No additional recorded actions are available for this player.'}
</p>{/if}
</section>
<section class="panel" aria-labelledby="restrictions-title"> <section class="panel" aria-labelledby="restrictions-title">
<div class="section-heading"> <div class="section-heading">
<h2 id="restrictions-title">Linked restrictions</h2> <h2 id="restrictions-title">Linked restrictions</h2>
@@ -461,9 +566,7 @@
})}>Revoke restriction</Button })}>Revoke restriction</Button
>{/if} >{/if}
</li>{/each} </li>{/each}
</ol>{:else}<p class="hint"> </ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
No restrictions have been applied from this case.
</p>{/if}
<p class="hint"> <p class="hint">
Applying or revoking a restriction does not change the case outcome. Applying or revoking a restriction does not change the case outcome.
</p> </p>
@@ -501,9 +604,18 @@
{admin} {admin}
coverage={report.coverage} coverage={report.coverage}
assignees={report.assignees} assignees={report.assignees}
linkedActions={report.linkedActions}
onsubmitted={submitted} onsubmitted={submitted}
/>{/key}{/if} />{/key}{/if}
{#if permitted && linkActionOpen}<LinkCaseActionDialog
bind:open={linkActionOpen}
caseId={item.id}
revision={item.revision}
actions={report.availableActions ?? []}
onsubmitted={actionLinked}
/>{/if}
<style> <style>
.case-page, .case-page,
.case-main, .case-main,
+27 -1
View File
@@ -1,5 +1,31 @@
import { serverSnapshot } from '$lib/server/fleet/snapshot.js'; import { serverSnapshot } from '$lib/server/fleet/snapshot.js';
import { error } from '@sveltejs/kit';
import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({ params }: { params: { name: string } }) { export function load({
params,
url,
locals,
setHeaders
}: {
params: { name: string };
url?: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query;
try {
query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams());
} catch {
error(400, 'Invalid match history filters.');
}
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
return { serverSnapshot: serverSnapshot(params.name) }; return { serverSnapshot: serverSnapshot(params.name) };
} }
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import MatchHistory from '../../src/lib/ui/components/MatchHistory.svelte';
import type { MatchHistoryResponse, ServerMatch } from '../../src/lib/match-history.js';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({
url: new URL('http://localhost/matches'),
data: { user: { role: 'moderator' as UserRole } }
});
const navigation = vi.hoisted(() => ({ goto: vi.fn().mockResolvedValue(undefined) }));
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
vi.mock('$app/navigation', () => ({ goto: navigation.goto }));
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
navigation.goto.mockClear();
});
const statuses: ServerMatch['status'][] = [
'completed',
'interrupted',
'end-report-missing',
'active'
];
const report: MatchHistoryResponse = {
total: 4,
page: 1,
limit: 25,
maps: [],
matches: statuses.map((status, index) => ({
id: index + 1,
game: 't5',
server: 't5_zombie_theater_1',
map: 'Kino Der Toten',
observedRound: 1,
finalRound: status === 'completed' ? 1 : null,
status,
reporterMatchKey: null,
missingReports: null,
startedAt: '2026-10-17T10:00:00Z',
lastReportedAt: '2026-10-17T10:01:00Z',
endedAt: status === 'completed' ? '2026-10-17T10:01:00Z' : null,
durationMs: null,
startPlayers: 1,
uniquePlayers: 1,
endPlayers: status === 'completed' ? 1 : null
}))
};
function show(search = '', role: UserRole = 'moderator') {
currentPage.set({
url: new URL(`http://localhost/matches${search}`),
data: { user: { role } }
});
component = mount(MatchHistory, { target: document.body, props: { initial: report } });
flushSync();
}
it('enables the moderation archive through a namespaced URL while preserving filters', async () => {
show('?matchGame=t5&matchPage=2');
document.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
await vi.waitFor(() => expect(navigation.goto).toHaveBeenCalledOnce());
expect(navigation.goto.mock.calls[0][0]).toBe('/matches?matchGame=t5&matchArchive=1');
});
it('links round-one and unfinished archive rows to retained match detail with return context', () => {
show('?matchArchive=1&matchGame=t5');
expect(document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(true);
const links = [...document.querySelectorAll<HTMLAnchorElement>('tbody a.match-link')];
expect(links).toHaveLength(4);
for (const [index, link] of links.entries()) {
const url = new URL(link.href);
expect(url.pathname).toBe(`/matches/${index + 1}`);
expect(url.searchParams.get('matchArchive')).toBe('1');
expect(url.searchParams.get('returnTo')).toBe('/matches?matchArchive=1&matchGame=t5');
}
expect(document.body.textContent).toContain('Highest observed');
expect(document.body.textContent).toContain('Last report');
});
it('keeps ordinary history navigation and hides archive controls from viewers', () => {
show('', 'viewer');
expect(document.querySelector('input[type="checkbox"]')).toBeNull();
const links = [...document.querySelectorAll<HTMLAnchorElement>('tbody a.match-link')];
expect(links).toHaveLength(1);
expect(new URL(links[0].href).searchParams.has('matchArchive')).toBe(false);
});
+152
View File
@@ -0,0 +1,152 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { writable } from 'svelte/store';
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({ data: { user: { role: 'moderator' as UserRole } } });
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const history: ContextualChatEntry[] = [
['11', 'Subject', 'A concerning reply'],
['22', 'Other player', 'The question that explains it'],
['11', 'Subject', 'A later reply']
].map(([guid, player, message], index) => ({
type: 'message',
matchId: 17,
messageId: `17:message-${index}:1`,
message: { guid, player, message, time: `1:0${index}`, slot: index },
context: { realTime: null, matchElapsedMs: index * 1000, round: 2 }
}));
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const response = (value: unknown) => new Response(JSON.stringify(value));
function show(role: UserRole = 'moderator') {
currentPage.set({ data: { user: { role } } });
component = mount(ChatPanel, { target: document.body, props: { game: 't5', history } });
flushSync();
}
function select(label: string, value: string) {
const labelNode = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
const node = document.querySelector<HTMLSelectElement>(`[aria-labelledby="${labelNode.id}"]`)!;
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();
}
const submit = () =>
document
.querySelector('dialog form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
it('selects messages across participants, previews their context, and attaches to an existing case', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
response({
cases: [{ id: 'case-1', title: 'Existing review', status: 'open', revision: 4 }],
total: 1
})
)
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
show();
expect(button('Attach to case').disabled).toBe(true);
const checkboxes = document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
checkboxes[0].click();
checkboxes[1].click();
flushSync();
expect(document.body.textContent).toContain('2 selected');
button('Attach to case').click();
await vi.waitFor(() =>
expect(document.querySelector('dialog')?.textContent).toContain('Existing review')
);
expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11');
expect(document.querySelector('dialog')?.textContent).toContain('GUID 22');
expect(document.querySelector('dialog')?.textContent).toContain('Message time unknown');
const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!;
expect(excerpt.querySelectorAll('li')).toHaveLength(3);
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
expect(excerpt.textContent).toContain('Selected context');
select('Destination', 'case-1');
submit();
await vi.waitFor(() => expect(document.body.textContent).toContain('Selected evidence saved.'));
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({
op: 'attach-evidence',
id: 'case-1',
revision: 4,
evidence: {
matchId: 17,
messageIds: ['17:message-0:1', '17:message-1:1'],
contextBefore: 3,
contextAfter: 3
},
idempotencyKey: expect.any(String)
});
expect(document.querySelector('a[href="/moderation/cases/case-1"]')).not.toBeNull();
expect(
[...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].every(
(node) => !node.checked
)
).toBe(true);
});
it('opens a new case with all selected messages and retries an uncertain request unchanged', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ cases: [], total: 0 }))
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValueOnce(response({ ok: true, id: 'new-case' }));
vi.stubGlobal('fetch', fetch);
show();
button('Select all').click();
flushSync();
button('Attach to case').click();
await vi.waitFor(() =>
expect(document.querySelector('dialog')?.textContent).toContain('No existing cases')
);
const title = document.querySelector<HTMLInputElement>('dialog input:not([type="number"])')!;
title.value = 'Conversation review';
title.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
submit();
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
button('Retry same request').click();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
expect(fetch.mock.calls[2][1].body).toBe(fetch.mock.calls[1][1].body);
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
op: 'create-case',
game: 't5',
guid: '11',
title: 'Conversation review',
evidence: { messageIds: history.map((entry) => entry.messageId) }
});
await tick();
});
it('keeps evidence selection unavailable to read-only viewers', () => {
show('super_viewer');
expect(document.querySelector('input[type="checkbox"]')).toBeNull();
expect(button('Attach to case')).toBeUndefined();
});
+214
View File
@@ -0,0 +1,214 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import CaseAssessment from '../../src/lib/ui/components/moderation/CaseAssessment.svelte';
import type { ModerationCaseAssessment } from '../../src/lib/moderation.js';
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const current: ModerationCaseAssessment = {
revision: 4,
summary: 'Slur documented; threatening intent unclear.',
rules: ['Rule 2: Respect other players'],
findings: [
{
category: 'hate_speech',
finding: 'substantiated',
detail: 'The preserved message contains a slur.'
},
{
category: 'threats',
finding: 'inconclusive',
detail: 'The reply could refer to gameplay.'
}
],
actor: 'Moderator One',
createdAt: '2026-10-17T10:00:00Z'
};
function show(
assessment: ModerationCaseAssessment | null = null,
permitted = true,
history: ModerationCaseAssessment[] = []
) {
const props = writable({ revision: 4, assessment });
const state = fromStore(props);
const saved = vi.fn();
component = mount(CaseAssessment, {
target: document.body,
props: {
caseId: 'case-1',
get revision() {
return state.current.revision;
},
get assessment() {
return state.current.assessment;
},
history,
permitted,
onsubmitted: saved
}
});
flushSync();
return {
saved,
update(revision: number, next = assessment) {
props.set({ revision, assessment: next });
flushSync();
}
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
function field<T extends HTMLTextAreaElement | HTMLSelectElement>(label: string): T {
const node = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
}
function enter(label: string, value: string) {
const node = field<HTMLTextAreaElement>(label);
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
function select(label: string, value: string) {
const node = field<HTMLSelectElement>(label);
const query = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: query(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
function complete() {
enter('Current summary', 'Preserved evidence supports a safeguarding review.');
enter('Applicable rules', ' Rule 4: Safeguarding\n\nRule 2: Respect ');
select('Concern 1', 'safeguarding');
select('Finding 1', 'inconclusive');
enter('Explanation 1', 'The surrounding messages establish age but intent remains unclear.');
}
it('creates structured findings and separate rule references with one concern per category', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const { saved } = show();
expect(document.body.textContent).toContain('Not assessed');
button('Assess case').click();
await tick();
complete();
button('Add finding').click();
flushSync();
expect(
[...field<HTMLSelectElement>('Concern 2').options].find(
(option) => option.value === 'safeguarding'
)?.disabled
).toBe(true);
select('Concern 2', 'threats');
select('Finding 2', 'unsubstantiated');
enter('Explanation 2', 'No threat is present in the preserved exchange.');
button('Save assessment').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'assess-case',
id: 'case-1',
revision: 4,
summary: 'Preserved evidence supports a safeguarding review.',
rules: ['Rule 4: Safeguarding', 'Rule 2: Respect'],
findings: [
{
category: 'safeguarding',
finding: 'inconclusive',
detail: 'The surrounding messages establish age but intent remains unclear.'
},
{
category: 'threats',
finding: 'unsubstantiated',
detail: 'No threat is present in the preserved exchange.'
}
],
idempotencyKey: expect.any(String)
});
});
it('shows current findings and preserves prior assessments for read-only viewers', () => {
const previous = {
...current,
revision: 2,
summary: 'Initial concern awaiting full context.',
actor: 'Moderator Two'
};
show(current, false, [current, previous]);
expect(document.body.textContent).toContain(current.summary);
expect(document.body.textContent).toContain('Substantiated');
expect(document.body.textContent).toContain('Inconclusive');
expect(document.querySelector('details summary')?.textContent).toBe('Previous assessments (1)');
expect(document.querySelector('details')?.textContent).toContain(previous.summary);
expect(document.querySelector('details')?.textContent).toContain('Moderator Two');
expect(button('Edit assessment')).toBeUndefined();
expect(document.querySelector('dialog')).toBeNull();
});
it('retains a conflicted assessment and uses the refreshed revision after reopening', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
response({ error: 'This case changed. Reload it before making another change.' }, 409)
)
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const { update, saved } = show(current);
button('Edit assessment').click();
await tick();
expect(field<HTMLTextAreaElement>('Current summary').value).toBe(current.summary);
enter('Current summary', 'Revised conclusion after review.');
button('Save assessment').click();
await vi.waitFor(() =>
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'This case changed.'
)
);
expect(field<HTMLTextAreaElement>('Current summary').value).toBe(
'Revised conclusion after review.'
);
expect(saved).not.toHaveBeenCalled();
button('Cancel').click();
await tick();
update(9);
button('Edit assessment').click();
await tick();
enter('Current summary', 'Reviewed against the refreshed case.');
document.querySelector<HTMLButtonElement>('[aria-label="Remove finding 2"]')!.click();
flushSync();
button('Save assessment').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
revision: 9,
findings: [current.findings[0]]
});
});
it('retries an uncertain assessment as the exact same request', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const { saved } = show();
button('Assess case').click();
await tick();
complete();
button('Save assessment').click();
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
button('Retry same request').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
});
+118 -1
View File
@@ -216,7 +216,8 @@ it('preserves original evidence context, linked restrictions, and staff history
'Restrict player', 'Restrict player',
'Revoke restriction', 'Revoke restriction',
'Add case note', 'Add case note',
'Attach evidence' 'Attach evidence',
'Link existing action'
]) ])
expect(button(label)).toBeUndefined(); expect(button(label)).toBeUndefined();
expect(document.querySelector('dialog')).toBeNull(); expect(document.querySelector('dialog')).toBeNull();
@@ -380,6 +381,122 @@ it('attaches archived evidence by match and exact message with revision checks',
}); });
}); });
it('links a prior mute with its recorded outcome without issuing another restriction', async () => {
const initial = detail();
const prior = {
id: 'earlier-mute',
type: 'restriction' as const,
kind: 'mute',
reason: 'Already applied before the report',
actor: 'Moderator Two',
createdAt: '2026-10-14T10:00:00Z',
state: 'expired',
expiresAt: '2026-10-14T11:00:00Z'
};
initial.availableActions = [prior];
initial.linkedActions = [];
const updated = detail();
updated.linkedActions = [prior];
updated.availableActions = [];
updated.case.revision = 5;
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValue(response(updated));
vi.stubGlobal('fetch', fetch);
show(initial);
button('Link existing action').click();
await tick();
select('Existing action', 'restriction:earlier-mute');
expect(document.querySelector('dialog[open]')?.textContent).toContain('Expired');
expect(document.querySelector('dialog[open]')?.textContent).toContain('Moderator Two');
enter(field('Link reason'), 'This mute addressed the reported chat');
button('Link action').click();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'link-case-action',
id: 'case-1',
revision: 4,
actionType: 'restriction',
actionId: 'earlier-mute',
reason: 'This mute addressed the reported chat',
idempotencyKey: expect.any(String)
});
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
'Already applied before the report'
);
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
'Expired'
);
expect(button('Link existing action').disabled).toBe(true);
});
it.each([
['warning_issued', 'action:warning-1', 'action', 'warning-1'],
['restriction_applied', 'restriction:restriction-1', 'restriction', 'restriction-1']
] as const)(
'references a linked action when recording %s',
async (outcome, selection, type, id) => {
const initial = detail();
initial.linkedActions = [
{
id: 'warning-1',
type: 'action',
kind: 'warn',
reason: 'Confirmed warning',
actor: 'Moderator One',
createdAt: '2026-10-15T10:00:00Z',
state: 'confirmed',
expiresAt: null
},
{
id: 'failed-warning',
type: 'action',
kind: 'warn',
reason: 'Undelivered warning',
actor: 'Moderator One',
createdAt: '2026-10-15T09:00:00Z',
state: 'failed',
expiresAt: null
},
{
id: 'restriction-1',
type: 'restriction',
kind: 'mute',
reason: 'Prior mute',
actor: 'Moderator One',
createdAt: '2026-10-15T11:00:00Z',
state: 'expired',
expiresAt: '2026-10-15T12:00:00Z'
}
];
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
.mockResolvedValue(response(initial));
vi.stubGlobal('fetch', fetch);
show(initial);
button('Review case').click();
await tick();
select('Outcome', outcome);
const options = [...field<HTMLSelectElement>('Supporting action').options].map(
(option) => option.value
);
expect(options).toEqual(['', selection]);
select('Supporting action', selection);
enter(field('Review reason'), 'Reviewed the recorded action');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'resolve-case',
id: 'case-1',
revision: 4,
status: outcome,
actionReference: { type, id }
});
}
);
it('retains a conflicted review and lets staff refresh before making another decision', async () => { it('retains a conflicted review and lets staff refresh before making another decision', async () => {
const updated = detail(); const updated = detail();
updated.case.revision = 8; updated.case.revision = 8;
+226
View File
@@ -0,0 +1,226 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import CaseFollowUp from '../../src/lib/ui/components/moderation/CaseFollowUp.svelte';
import CaseUpdates from '../../src/lib/ui/components/moderation/CaseUpdates.svelte';
import type {
ModerationCase,
ModerationCaseAction,
ModerationCaseNotification
} from '../../src/lib/moderation.js';
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const item: ModerationCase = {
id: 'case-1',
game: 't5',
guid: '123',
title: 'Chat review',
status: 'resolved',
priority: 'normal',
assignee: null,
assigneeId: null,
actor: 'Moderator One',
createdAt: '2026-10-16T10:00:00Z',
updatedAt: '2026-10-16T10:00:00Z',
revision: 6,
evidence: []
};
const mute: ModerationCaseAction = {
id: 'mute-1',
type: 'restriction',
kind: 'mute',
reason: 'Earlier chat abuse',
actor: 'Moderator One',
createdAt: '2026-10-16T10:00:00Z',
state: 'active',
expiresAt: '2026-10-17T10:00:00Z'
};
function show(initial = item, permitted = true) {
const saved = vi.fn();
component = mount(CaseFollowUp, {
target: document.body,
props: {
item: initial,
actions: [mute, { ...mute, id: 'permanent', expiresAt: null }],
checkedAt: '2026-10-17T12:00:00Z',
permitted,
onsubmitted: saved
}
});
flushSync();
return saved;
}
const button = (label: string, dialog = false) =>
[...document.querySelectorAll(dialog ? 'dialog button' : 'button')].find(
(node) => node.textContent?.trim() === label
)! as HTMLButtonElement;
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
label: string
): T {
const node = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
}
function enter(label: string, value: string) {
const node = field<HTMLInputElement | HTMLTextAreaElement>(label);
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
function select(label: string, value: string) {
const node = field<HTMLSelectElement>(label);
const query = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: query(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
it('schedules a closed case using a browser-local date converted to canonical ISO', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
button('Schedule review').click();
await tick();
enter('Review date (browser local time)', '2026-10-19T14:30');
enter('Follow-up reason', 'Check for additional reports');
button('Save follow-up').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'schedule-case-review',
id: 'case-1',
revision: 6,
reviewAt: new Date('2026-10-19T14:30').toISOString(),
reason: 'Check for additional reports',
idempotencyKey: expect.any(String)
});
});
it('uses the linked finite mute ID to schedule an exact expiration review', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
button('Schedule review').click();
await tick();
expect(
[...field<HTMLSelectElement>('Review timing').options].map((option) => option.value)
).toEqual(['custom', 'mute-1']);
select('Review timing', 'mute-1');
enter('Follow-up reason', 'Review after mute expires');
button('Save follow-up').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
const payload = JSON.parse(fetch.mock.calls[0][1].body);
expect(payload).toMatchObject({
op: 'schedule-case-review',
restrictionId: 'mute-1',
revision: 6
});
expect(payload).not.toHaveProperty('reviewAt');
});
it('shows overdue reviews and clears the date with a recorded result', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show({
...item,
reviewAt: '2026-10-17T10:00:00Z',
reviewReason: 'Check once the mute ends',
reviewSanctionId: 'mute-1'
});
expect(document.body.textContent).toContain('Overdue review');
expect(document.body.textContent).toContain('Review after linked mute expires.');
button('Complete or clear review').click();
await tick();
enter('Review result or clearing reason', 'Review complete; no further reports');
button('Complete or clear review', true).click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'schedule-case-review',
id: 'case-1',
revision: 6,
reviewAt: null,
reason: 'Review complete; no further reports'
});
});
it('shows overdue follow-up details without edit controls for read-only viewers', () => {
show({ ...item, reviewAt: '2026-10-17T10:00:00Z', reviewReason: 'Review reports' }, false);
expect(document.body.textContent).toContain('Overdue review');
expect(document.body.textContent).toContain('Review reports');
expect(button('Reschedule review')).toBeUndefined();
expect(button('Complete or clear review')).toBeUndefined();
});
const notification = (id: number): ModerationCaseNotification => ({
id,
caseId: 'case-1',
caseTitle: item.title,
kind: id === 3 ? 'assignment' : 'evidence',
createdAt: '2026-10-17T10:00:00Z',
readAt: null
});
it('acknowledges only loaded updates and preserves the read-through ID on an uncertain retry', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const values = writable([notification(3), notification(9)]);
const state = fromStore(values);
const saved = vi.fn();
component = mount(CaseUpdates, {
target: document.body,
props: {
caseId: 'case-1',
get notifications() {
return state.current;
},
unread: 2,
permitted: true,
onsubmitted: saved
}
});
flushSync();
expect(document.body.textContent).toContain('Assignment changed');
expect(document.body.textContent).toContain('Evidence or note added');
button('Mark updates as seen').click();
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
values.set([notification(12), notification(9), notification(3)]);
flushSync();
button('Retry same request').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'read-case-updates',
id: 'case-1',
throughId: 9,
idempotencyKey: expect.any(String)
});
});
it('does not offer notification acknowledgement to read-only viewers', () => {
component = mount(CaseUpdates, {
target: document.body,
props: {
caseId: 'case-1',
notifications: [notification(3)],
unread: 1,
permitted: false,
onsubmitted: vi.fn()
}
});
flushSync();
expect(document.body.textContent).toContain('1 unread');
expect(button('Mark updates as seen')).toBeUndefined();
});
+68 -3
View File
@@ -324,13 +324,35 @@ it('opens a newly created case page after preserving selected evidence', async (
it('links case records to workspaces and preserves case filters while paging', () => { it('links case records to workspaces and preserves case filters while paging', () => {
const data = overview('cases'); const data = overview('cases');
data.query = { ...data.query, caseStatus: 'active', priority: 'high', assignedTo: 'me' }; data.query = {
...data.query,
caseStatus: 'active',
priority: 'high',
assignedTo: 'me',
review: 'due'
};
data.cases = [ data.cases = [
{ {
id: 'case-1', id: 'case-1',
game: 't5', game: 't5',
guid, guid,
title: 'Chat investigation', title: 'Chat investigation',
reviewAt: '2026-10-14T12:00:00Z',
unreadUpdates: 2,
assessment: {
revision: 3,
summary: 'The threat remains ambiguous.',
rules: [],
findings: [
{
category: 'threats',
finding: 'inconclusive',
detail: 'Context is insufficient.'
}
],
actor: 'Moderator',
createdAt: '2026-10-16T12:00:00Z'
},
status: 'escalated', status: 'escalated',
priority: 'high', priority: 'high',
assigneeId: 'staff-1', assigneeId: 'staff-1',
@@ -343,20 +365,63 @@ it('links case records to workspaces and preserves case filters while paging', (
} }
]; ];
data.total = 40; data.total = 40;
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me'); show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me&review=due');
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe( expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
'Chat investigation' 'Chat investigation'
); );
expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated'); expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator'); expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
expect(document.querySelector('#moderation-results')?.textContent).toContain(
'The threat remains ambiguous.'
);
expect(document.querySelector('[aria-label="Assessment findings"]')?.textContent).toMatch(
/Threats\s*·\s*Inconclusive/
);
button('Next').click(); button('Next').click();
const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost'); const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
expect(destination.searchParams.get('caseStatus')).toBe('active'); expect(destination.searchParams.get('caseStatus')).toBe('active');
expect(destination.searchParams.get('priority')).toBe('high'); expect(destination.searchParams.get('priority')).toBe('high');
expect(destination.searchParams.get('assignedTo')).toBe('me'); expect(destination.searchParams.get('assignedTo')).toBe('me');
expect(destination.searchParams.get('review')).toBe('due');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Overdue review');
expect(document.querySelector('#moderation-results')?.textContent).toContain(
'2 unread updates'
);
expect(destination.searchParams.get('offset')).toBe('25'); expect(destination.searchParams.get('offset')).toBe('25');
button('Reset').click(); button('Reset').click();
const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost'); const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'offset']) for (const filter of ['caseStatus', 'priority', 'assignedTo', 'review', 'offset'])
expect(reset.searchParams.has(filter)).toBe(false); expect(reset.searchParams.has(filter)).toBe(false);
}); });
it('shows personal evidence and assignment notifications with links into the actionable queue', () => {
const data = overview('cases');
data.unreadCount = 2;
data.notifications = [
{
id: 1,
caseId: 'follow-up',
caseTitle: 'Review context',
kind: 'evidence',
createdAt: data.checkedAt,
readAt: null
},
{
id: 2,
caseId: 'appeal',
caseTitle: 'Review appeal',
kind: 'assignment',
createdAt: data.checkedAt,
readAt: null
}
];
show(data);
const notifications = document.querySelector('[aria-label="Your case notifications"]')!;
expect(notifications.textContent).toContain('Evidence or note added');
expect(notifications.textContent).toContain('Assignment changed');
expect(notifications.querySelector('a[href="/moderation/cases/follow-up"]')).not.toBeNull();
expect(
document.querySelector('a[href="/moderation?view=cases&review=updates"]')
).not.toBeNull();
expect(document.querySelector('a[href="/moderation?view=cases&review=due"]')).not.toBeNull();
});
@@ -0,0 +1,187 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import CaseRelationships from '../../src/lib/ui/components/moderation/CaseRelationships.svelte';
import ModerationDialog from '../../src/lib/ui/components/moderation/ModerationDialog.svelte';
import type { ModerationCaseRelationship } from '../../src/lib/moderation.js';
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const candidate = {
id: 'case-2',
title: 'Earlier report',
status: 'open' as const,
game: 't5' as const,
guid: '123',
sharedMatch: true
};
const relationship: ModerationCaseRelationship = {
id: 'relationship-1',
relation: 'appeal_of',
direction: 'incoming',
case: candidate,
actor: 'Moderator One',
createdAt: '2026-10-17T10:00:00Z'
};
function show(permitted = true, relationships: ModerationCaseRelationship[] = []) {
const saved = vi.fn();
component = mount(CaseRelationships, {
target: document.body,
props: {
caseId: 'case-1',
revision: 6,
relationships,
candidates: [candidate],
permitted,
onsubmitted: saved
}
});
flushSync();
return saved;
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
label: string
): T {
const node = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
}
function enter(label: string, value: string) {
const node = field<HTMLInputElement | HTMLTextAreaElement>(label);
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
function select(label: string, value: string) {
const node = field<HTMLSelectElement>(label);
const query = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: query(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
it.each(['candidate', 'url'])(
'links a %s with an explicit relationship and reason',
async (source) => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
expect(document.body.textContent).toContain('Shared archived match');
button('Link case').click();
await tick();
if (source === 'candidate') {
select('Related case', 'case-2');
select('Relationship', 'duplicate_of');
} else enter('Case ID or URL', 'https://example.com/moderation/cases/case-2?view=history');
enter('Relationship reason', 'Both reports refer to the same exchange');
button('Link cases').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'link-case',
id: 'case-1',
revision: 6,
relatedCaseId: 'case-2',
relation: source === 'candidate' ? 'duplicate_of' : 'same_incident',
reason: 'Both reports refer to the same exchange',
idempotencyKey: expect.any(String)
});
}
);
it('displays inverse relationships and removes links only with a recorded reason', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show(true, [relationship]);
expect(document.body.textContent).toContain('Appealed by');
button('Remove link').click();
await tick();
expect(field<HTMLTextAreaElement>('Relationship reason').required).toBe(true);
enter('Relationship reason', 'Linked to the wrong incident');
button('Remove relationship').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'unlink-case',
id: 'case-1',
revision: 6,
relationshipId: 'relationship-1',
reason: 'Linked to the wrong incident'
});
});
it('lets read-only viewers inspect candidate hints and inverse relationships', () => {
show(false, [
relationship,
{ ...relationship, id: 'relationship-2', relation: 'duplicate_of' }
]);
expect(document.body.textContent).toContain('Appealed by');
expect(document.body.textContent).toContain('Has duplicate');
expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull();
expect(button('Link case')).toBeUndefined();
expect(button('Remove link')).toBeUndefined();
});
it('shows existing cases in the generic case creation dialog', async () => {
const fetch = vi.fn().mockResolvedValue(response({ cases: [candidate], total: 31 }));
vi.stubGlobal('fetch', fetch);
component = mount(ModerationDialog, {
target: document.body,
props: {
open: true,
mode: 'case',
coverage: [],
admin: false,
seed: { game: 't5', guid: '123' },
onsubmitted: vi.fn()
}
});
flushSync();
await vi.waitFor(() => expect(document.body.textContent).toContain('31 existing cases'));
expect(fetch.mock.calls[0][0]).toBe('/api/moderation?view=cases&game=t5&guid=123');
expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull();
expect(document.body.textContent).toContain('View all cases for this player');
});
it('allows case creation when duplicate lookup fails', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Lookup unavailable'))
.mockResolvedValue(response({ ok: true, id: 'new-case' }));
vi.stubGlobal('fetch', fetch);
const saved = vi.fn();
component = mount(ModerationDialog, {
target: document.body,
props: {
open: true,
mode: 'case',
coverage: [],
admin: false,
seed: { game: 't5', guid: '123' },
onsubmitted: saved
}
});
flushSync();
await vi.waitFor(() =>
expect(document.body.textContent).toContain('Existing cases could not be loaded')
);
enter('Case title', 'Distinct incident');
button('Save').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Distinct incident'
});
});
+26
View File
@@ -123,6 +123,32 @@ function expectExplanation(totals: boolean) {
} }
describe('match result score presentation', () => { describe('match result score presentation', () => {
it.each(['interrupted', 'end-report-missing', 'active'] as const)(
'describes %s archives as observed reports without final result claims',
(status) => {
const archived = match([player({ highestRound: 1, presentAtEnd: false })]);
archived.status = status;
archived.observedRound = 1;
archived.finalRound = null;
archived.endedAt = null;
archived.endPlayers = null;
archived.durationMs = null;
show(archived);
expect(document.body.textContent).toContain(
status === 'interrupted'
? 'Interrupted'
: status === 'end-report-missing'
? 'End report missing'
: 'Not reporting'
);
expect(document.body.textContent).toContain('Highest observed round');
expect(document.body.textContent).toContain('Last reported');
expect(document.querySelector('tbody')?.textContent).toContain('Recorded participant');
expect(document.body.textContent).not.toContain('At match end');
expect(document.body.textContent).not.toContain('Players at end');
expect(document.body.textContent).not.toContain('Final round');
}
);
it('shows recorded totals instead of balances, including totals for departed players', () => { it('shows recorded totals instead of balances, including totals for departed players', () => {
show(match([player(), player({ guid: '202', presentAtEnd: false, scoreTotal: 6500 })])); show(match([player(), player({ guid: '202', presentAtEnd: false, scoreTotal: 6500 })]));
expectScores('Total score', ['8,470', '6,500']); expectScores('Total score', ['8,470', '6,500']);
+62 -1
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture; let fixture: Fixture;
@@ -10,7 +11,30 @@ afterEach(async () => {
await fixture.cleanup(); await fixture.cleanup();
}); });
it('indexes campaign cascades and beta evidence on fresh and reopened databases', async () => { function expectModerationReferenceIndexes(handle: Database.Database) {
for (const [query, index] of [
[
"SELECT id FROM moderation_cases WHERE review_sanction_id='restriction'",
'moderation_case_review_sanction'
],
[
"SELECT id FROM moderation_case_relationships WHERE case_id='source'",
'moderation_case_relationship_source'
],
[
"SELECT id FROM moderation_case_notifications WHERE case_id='source'",
'moderation_case_notifications_parent'
]
]) {
const plan = handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all() as { detail: string }[];
expect(
plan.some((row) => row.detail.includes(index)),
query
).toBe(true);
}
}
it('indexes campaign, beta and moderation references on fresh and reopened databases', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js');
for (let pass = 0; pass < 2; pass++) { for (let pass = 0; pass < 2; pass++) {
const handle = db(); const handle = db();
@@ -29,11 +53,48 @@ it('indexes campaign cascades and beta evidence on fresh and reopened databases'
}[]; }[];
expect(plan.some((row) => row.detail.includes(index))).toBe(true); expect(plan.some((row) => row.detail.includes(index))).toBe(true);
} }
expectModerationReferenceIndexes(handle);
expect(handle.pragma('foreign_key_check')).toEqual([]); expect(handle.pragma('foreign_key_check')).toEqual([]);
closeDb(); closeDb();
} }
}); });
it('adds complete moderation reference indexes when upgrading version 34 without changing records', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 34));
legacy.exec(`
INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('moderator','moderator','Moderator','unused','moderator',0,0);
INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('source','t5','123','Source case','open','moderator','Moderator','2026-10-17','2026-10-17'),
('target','t5','123','Target case','open','moderator','Moderator','2026-10-17','2026-10-17');
INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at)
VALUES('restriction','t5','123','mute','game','Recorded restriction','','moderator','Moderator','2026-10-17');
UPDATE moderation_cases SET review_sanction_id='restriction' WHERE id='source';
INSERT INTO moderation_case_relationships VALUES('relationship','source','target','same_incident','moderator','Moderator','2026-10-17','2026-10-18');
INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at)
VALUES('moderator','source','evidence','2026-10-17');
`);
const records = () => ({
cases: legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all(),
relationships: legacy.prepare('SELECT * FROM moderation_case_relationships').all(),
notifications: legacy.prepare('SELECT * FROM moderation_case_notifications').all()
});
const before = records();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expectModerationReferenceIndexes(legacy);
expect(records()).toEqual(before);
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});
it('covers every foreign key child prefix, including host and library schemas', async () => { it('covers every foreign key child prefix, including host and library schemas', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const handle = db(); const handle = db();
+32 -1
View File
@@ -51,11 +51,42 @@ describe('game chat', () => {
'1:00 say;123;bad;Player;hello', '1:00 say;123;bad;Player;hello',
'1:00 say;123;0;Player;\x15^7', '1:00 say;123;0;Player;\x15^7',
'1:00 InitGame: say;123;0;Player;hello', '1:00 InitGame: say;123;0;Player;hello',
'1:00 sayteam;123;0;Player;hello' '1:00 sayteam;123;bad;Player;hello',
'1:00 sayteam;123;0;Player;\x15^7'
]) ])
).toEqual([]); ).toEqual([]);
}); });
it('keeps team and public chat in order with distinct channels and stable public identities', () => {
const messages = parseGameChat([
'1:00 say;123;0;Player;hello; café',
'1:00 sayteam;123;0;^2Player;\x15hello; café',
'1:01 sayteam;456;1;Teammate;reply'
]);
expect(messages).toEqual([
{ time: '1:00', guid: '123', slot: 0, player: 'Player', message: 'hello; café' },
{
time: '1:00',
guid: '123',
slot: 0,
player: 'Player',
message: 'hello; café',
channel: 'team'
},
{
time: '1:01',
guid: '456',
slot: 1,
player: 'Teammate',
message: 'reply',
channel: 'team'
}
]);
expect(JSON.stringify(messages[0])).toBe(
'{"time":"1:00","guid":"123","slot":0,"player":"Player","message":"hello; café"}'
);
});
it('separates matches even when elapsed time keeps increasing', () => { it('separates matches even when elapsed time keeps increasing', () => {
const entries = parseGameChatTimeline([ const entries = parseGameChatTimeline([
'1200:00 InitGame: \\mapname\\zombie_theater', '1200:00 InitGame: \\mapname\\zombie_theater',
+273
View File
@@ -0,0 +1,273 @@
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { moderationConcernCategories } from '../../src/lib/moderation.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'moderator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
const create = () =>
mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct' });
const draft = {
summary: 'The documented slur is substantiated; the threatening intent remains unclear.',
rules: ['Rule 1: No discriminatory abuse', 'Rule 2: No threats'],
findings: [
{
category: 'hate_speech',
finding: 'substantiated',
detail: 'The preserved message contains a slur.'
},
{
category: 'threats',
finding: 'inconclusive',
detail: 'The context does not establish a threat.'
}
]
};
const assess = (id: string, revision: number, overrides: Record<string, unknown> = {}) => ({
op: 'assess-case',
id,
revision,
...draft,
...overrides
});
it('preserves current and historical assessments with case revisions, including closed cases', async () => {
const { id } = await create();
expect(await detail(id)).toMatchObject({ assessment: null, assessmentHistory: [] });
const request = { ...assess(id, 1), idempotencyKey: randomUUID() };
await mutate(request);
await mutate(request);
const first = (await detail(id)).assessment!;
expect(first).toEqual({ ...draft, revision: 2, actor: 'moderator', createdAt: clock });
await mutate({
op: 'resolve-case',
id,
revision: 2,
status: 'no_action',
reason: 'Initial review complete'
});
vi.setSystemTime(new Date(Date.parse(clock) + 60_000));
const updated = {
summary: 'Further context does not support a threat.',
rules: [],
findings: [
{
category: 'threats',
finding: 'unsubstantiated',
detail: 'The reply was about the game.'
}
]
};
await mutate(assess(id, 3, updated), 'admin');
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ revision: 4, status: 'no_action' });
expect(data.assessment).toEqual({
...updated,
revision: 4,
actor: 'admin',
createdAt: '2026-10-17T12:01:00.000Z'
});
expect(data.assessmentHistory).toEqual([data.assessment, first]);
expect(JSON.parse(data.events[0].detail)).toEqual({
assessmentRevision: 4,
summary: updated.summary
});
const { currentCaseAssessment } =
await import('../../src/lib/server/moderation/assessments.js');
expect(currentCaseAssessment(id)).toEqual(data.assessment);
expect(currentCaseAssessment('missing')).toBeNull();
expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual({ revision: 1 });
});
it('checks staff access, case revision and normalized no-change submissions before writing history', async () => {
const { id } = await create();
for (const actor of ['viewer', 'super_viewer'])
await expect(mutate(assess(id, 1), actor)).rejects.toMatchObject({ status: 403 });
await mutate(assess(id, 1));
const before = await detail(id);
await expect(mutate(assess(id, 1, { summary: 'Stale edit' }))).rejects.toMatchObject({
status: 409
});
await expect(mutate(assess(id, 2, { summary: ` ${draft.summary} ` }))).rejects.toThrow(
'Change the assessment'
);
expect(await detail(id)).toEqual(before);
await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run();
await expect(mutate(assess(id, 2, { summary: 'Disabled account edit' }))).rejects.toMatchObject(
{ status: 403 }
);
});
it('rejects invalid summaries, rules and findings without adding assessment or audit rows', async () => {
const { id } = await create();
const invalid = [
{ summary: '' },
{ summary: 'a'.repeat(2001) },
{ summary: 'Invalid\u0000summary' },
{ rules: null },
{ rules: [''] },
{ rules: ['a'.repeat(161)] },
{ rules: Array(21).fill('Rule') },
{ findings: [] },
{ findings: null },
{ findings: [null] },
{ findings: [{ category: 'unknown', finding: 'substantiated', detail: 'Evidence' }] },
{ findings: [{ category: 'spam', finding: 'unknown', detail: 'Evidence' }] },
{ findings: [{ category: 'spam', finding: 'substantiated', detail: '' }] },
{ findings: [{ category: 'spam', finding: 'substantiated', detail: 'a'.repeat(1001) }] },
{ findings: [draft.findings[0], draft.findings[0]] },
{ findings: Array(8).fill(draft.findings[0]) }
];
const before = await detail(id);
for (const input of invalid)
await expect(mutate(assess(id, 1, input))).rejects.toMatchObject({ status: 400 });
expect(await detail(id)).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]);
});
it('rolls back history, revisions and request receipts when assessment auditing fails', async () => {
const { id } = await create();
const before = await detail(id);
handle.exec(`CREATE TRIGGER fail_assessment_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.assessed' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const request = { ...assess(id, 1), idempotencyKey: randomUUID() };
await expect(mutate(request)).rejects.toThrow('audit unavailable');
expect(await detail(id)).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
handle.exec('DROP TRIGGER fail_assessment_audit');
await mutate(request);
expect((await detail(id)).assessmentHistory).toHaveLength(1);
});
it('keeps assessment revisions after source match, server and author cleanup', async () => {
handle
.prepare(
"INSERT INTO servers(name,game,map) VALUES('t5_zombie_theater_1','t5','zombie_theater')"
)
.run();
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({
server: 't5_zombie_theater_1',
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Player' }],
receivedAt: clock
});
const { id } = await create();
await mutate(assess(id, 1));
const before = (await detail(id)).assessmentHistory;
handle.prepare('DELETE FROM leaderboard_matches').run();
handle.prepare('DELETE FROM servers').run();
handle.prepare("DELETE FROM auth_users WHERE id='moderator'").run();
expect((await detail(id, 'admin')).assessmentHistory).toEqual(before);
});
it('accepts a valid detailed multibyte assessment over 16 KiB while retaining the 64 KiB request boundary', async () => {
const { id } = await create();
const { POST } = await import('../../src/routes/api/moderation/+server.js');
const { getUser } = await import('../../src/lib/server/auth/index.js');
const input = {
...assess(id, 1, {
summary: '界'.repeat(2000),
rules: Array.from({ length: 20 }, (_, index) => `${index} ${'界'.repeat(155)}`),
findings: moderationConcernCategories.map((category) => ({
category,
finding: 'inconclusive',
detail: '界'.repeat(1000)
}))
}),
idempotencyKey: randomUUID()
};
const body = JSON.stringify(input);
expect(Buffer.byteLength(body)).toBeGreaterThan(16 * 1024);
const post = (body: string) =>
POST({
locals: { user: getUser('moderator') },
request: new Request('http://localhost/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
})
} as Parameters<typeof POST>[0]);
const response = await post(body);
expect(response.status).toBe(200);
expect((await detail(id)).assessment?.findings).toHaveLength(7);
const oversized = await post(
JSON.stringify({ ...input, revision: 2, padding: 'a'.repeat(64 * 1024) })
);
expect(oversized.status).toBe(413);
expect((await detail(id)).assessmentHistory).toHaveLength(1);
});
it('adds assessment storage to an existing database without altering case records', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 31));
legacy
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
VALUES('case','t5','123','Existing case','no_action','former','Former staff',?,?,41)`
)
.run(clock, clock);
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
const insert = legacy.prepare(
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
);
insert.run('case', 42, JSON.stringify(draft), 'former', 'Former staff', clock);
expect(() => insert.run('case', 42, '{}', 'former', 'Former staff', clock)).toThrow(
'UNIQUE'
);
expect(() => insert.run('missing', 2, '{}', 'former', 'Former staff', clock)).toThrow(
'FOREIGN KEY'
);
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});
+331
View File
@@ -0,0 +1,331 @@
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'moderator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
const create = (overrides: Record<string, unknown> = {}) =>
mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct', ...overrides });
const restriction = (overrides: Record<string, unknown> = {}, actor = 'moderator') =>
mutate(
{
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'mute',
scope: 'game',
durationMinutes: 60,
reason: 'Earlier moderation action',
...overrides
},
actor
);
function action(overrides: Record<string, string | null> = {}) {
const record = {
id: randomUUID(),
game: 't5',
guid: '123',
kind: 'warn',
state: 'confirmed',
...overrides
};
handle
.prepare(
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,
kind,reason,actor_id,actor,created_at,expires_at,state)
VALUES(?,?,?,'retired-server','retired-instance',1,'old-session',?,
'Earlier warning','moderator','Former moderator',?,?,?)`
)
.run(record.id, record.game, record.guid, record.kind, clock, clock, record.state);
handle
.prepare(
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
VALUES(?,?,'Former moderator',?,?,'Recorded delivery result',?)`
)
.run(record.game, record.guid, `action.${record.state}`, record.id, clock);
return record.id as string;
}
function enforcement() {
return {
sanctions: handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all(),
actions: handle.prepare('SELECT * FROM moderation_actions ORDER BY id').all(),
policy: handle.prepare('SELECT * FROM moderation_policy').all()
};
}
const link = (id: string, revision: number, actionType: string, actionId: string) => ({
op: 'link-case-action',
id,
revision,
actionType,
actionId,
reason: 'This earlier action addresses the incident under review.'
});
it('links a prior restriction to multiple cases without changing enforcement or its original case', async () => {
const original = await create();
const sanction = await restriction({ caseId: original.id, caseRevision: 1 });
const current = await create();
const before = enforcement();
const request = {
...link(current.id, 1, 'restriction', sanction.id),
idempotencyKey: randomUUID()
};
await mutate(request);
await mutate(request);
let data = await detail(current.id);
expect(data.case.revision).toBe(2);
expect(data.sanctions).toMatchObject([{ id: sanction.id, caseId: original.id }]);
expect(data.linkedActions).toMatchObject([
{ id: sanction.id, type: 'restriction', state: 'active' }
]);
expect(data.availableActions).toEqual([]);
expect(data.events.map((event) => event.action)).toContain('restriction.created');
expect((await detail(original.id)).linkedActions).toMatchObject([{ id: sanction.id }]);
expect(enforcement()).toEqual(before);
await mutate(link(current.id, 2, 'restriction', sanction.id));
expect(await detail(current.id)).toEqual(data);
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_action_links').get()
).toEqual({ count: 1 });
const event = data.events.find((entry) => entry.action === 'case.action_linked')!;
expect(JSON.parse(event.detail)).toEqual({
reason: request.reason,
actionReference: { type: 'restriction', id: sanction.id }
});
await mutate({
op: 'revoke-sanction',
id: sanction.id,
revision: 1,
reason: 'Review completed'
});
data = await detail(current.id);
expect(data.case.revision).toBe(3);
expect((await detail(original.id)).case.revision).toBe(3);
expect(data.linkedActions?.[0].state).toBe('revoked');
expect(data.events[0].action).toBe('restriction.revoked');
});
it('links historical live actions to closed cases and exposes their real delivery state and timeline', async () => {
const warningId = action();
const failedId = action({ state: 'failed' });
action({ game: 't6' });
action({ guid: '456' });
action({ guid: null, kind: 'announce' });
const { id } = await create();
expect((await detail(id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
[warningId, failedId].sort()
);
await mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'no_action',
reason: 'Initial review complete'
});
const before = enforcement();
await mutate(link(id, 2, 'action', warningId));
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ status: 'no_action', revision: 3 });
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
expect(enforcement()).toEqual(before);
await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
});
it('rejects unauthorized, stale, missing and wrong-player or game links and permits a matching fleet restriction', async () => {
const { id } = await create();
const right = action();
const otherPlayer = action({ guid: '456' });
const otherGame = action({ game: 't6' });
const wrongRestriction = await restriction({ guid: '456' });
for (const actor of ['super_viewer', 'viewer'])
await expect(mutate(link(id, 1, 'action', right), actor)).rejects.toMatchObject({
status: 403
});
await expect(mutate(link(id, 2, 'action', right))).rejects.toMatchObject({ status: 409 });
for (const [type, source] of [
['action', otherPlayer],
['action', otherGame],
['action', 'missing'],
['restriction', wrongRestriction.id]
])
await expect(mutate(link(id, 1, type, source))).rejects.toMatchObject({ status: 404 });
expect((await detail(id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
for (const game of ['t5', 't6'])
handle
.prepare('INSERT INTO moderation_aliases VALUES(?,?,?,?,?)')
.run(game, '123', 'Player', clock, clock);
const fleet = await restriction({ scope: 'fleet' }, 'admin');
const t6 = await create({ game: 't6' });
expect((await detail(t6.id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
[fleet.id, otherGame].sort()
);
await mutate(link(t6.id, 1, 'restriction', fleet.id));
expect((await detail(t6.id)).sanctions).toMatchObject([
{ id: fleet.id, scope: 'fleet', game: 't5' }
]);
});
it('requires a linked confirmed warning for a cited warning outcome and preserves the actual reference', async () => {
const { id } = await create();
const confirmed = action();
const failed = action({ state: 'failed' });
const kick = action({ kind: 'kick' });
const sanction = await restriction();
const resolve = (
revision: number,
type: string,
actionId: string,
status = 'warning_issued'
) => ({
op: 'resolve-case',
id,
revision,
status,
reason: 'Decision after evidence review',
actionReference: { type, id: actionId }
});
await expect(mutate(resolve(1, 'action', confirmed))).rejects.toThrow(
'Link the recorded action'
);
await mutate(link(id, 1, 'action', failed));
await mutate(link(id, 2, 'action', kick));
await mutate(link(id, 3, 'restriction', sanction.id));
for (const [type, source] of [
['action', failed],
['action', kick],
['restriction', sanction.id]
])
await expect(mutate(resolve(4, type, source))).rejects.toThrow('confirmed warning');
await mutate(link(id, 4, 'action', confirmed));
await expect(mutate(resolve(5, 'action', confirmed, 'restriction_applied'))).rejects.toThrow(
'recorded restriction'
);
const before = enforcement();
await mutate(resolve(5, 'action', confirmed));
const data = await detail(id);
expect(data.case).toMatchObject({ status: 'warning_issued', revision: 6 });
expect(JSON.parse(data.events[0].detail)).toEqual({
reason: 'Decision after evidence review',
actionReference: { type: 'action', id: confirmed }
});
expect(enforcement()).toEqual(before);
});
it('allows a historical expired restriction created from the original case to support its closure', async () => {
const { id } = await create();
const sanction = await restriction({ caseId: id, caseRevision: 1 });
vi.setSystemTime(new Date(Date.parse(clock) + 120 * 60_000));
expect((await detail(id)).linkedActions).toMatchObject([{ id: sanction.id, state: 'expired' }]);
const before = enforcement();
await mutate({
op: 'resolve-case',
id,
revision: 2,
status: 'restriction_applied',
reason: 'Earlier mute addressed the incident',
actionReference: { type: 'restriction', id: sanction.id }
});
expect((await detail(id)).case.status).toBe('restriction_applied');
expect(enforcement()).toEqual(before);
});
it('rolls back a link, case revision and idempotency record if its audit write fails', async () => {
const { id } = await create();
const warning = action();
const before = await detail(id);
handle.exec(`CREATE TRIGGER reject_case_action_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.action_linked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const request = { ...link(id, 1, 'action', warning), idempotencyKey: randomUUID() };
await expect(mutate(request)).rejects.toThrow('audit unavailable');
expect(await detail(id)).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
handle.exec('DROP TRIGGER reject_case_action_audit');
await mutate(request);
expect((await detail(id)).linkedActions).toMatchObject([{ id: warning }]);
});
it('upgrades existing databases without changing original case restrictions and enforces link identity', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 30));
legacy
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('case','t5','123','Prior case','open','former','Former staff',?,?)`
)
.run(clock, clock);
legacy
.prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
VALUES('restriction','t5','123','mute','game','Prior restriction','','former','Former staff',?,'case')`
)
.run(clock);
const before = legacy.prepare('SELECT * FROM moderation_sanctions').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_sanctions').all()).toEqual(before);
const insert = legacy.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)');
insert.run('case', 'restriction', 'restriction', 'Moderator', clock);
expect(() => insert.run('case', 'restriction', 'restriction', 'Moderator', clock)).toThrow(
'UNIQUE'
);
expect(() => insert.run('missing', 'action', 'action', 'Moderator', clock)).toThrow(
'FOREIGN KEY'
);
expect(() => insert.run('case', 'other', 'action', 'Moderator', clock)).toThrow('CHECK');
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});
@@ -67,3 +67,36 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
).total ).total
).toBe(1); ).toBe(1);
}); });
it('shows the latest assessment in case queues while preserving earlier findings', async () => {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
const mutate = (input: Record<string, unknown>) =>
mutateModeration('first', { ...input, idempotencyKey: randomUUID() });
const { id } = mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Conversation review'
});
for (const [revision, finding, summary] of [
[1, 'inconclusive', 'Need more context'],
[2, 'substantiated', 'Full excerpt confirms targeted harassment']
] as const)
mutate({
op: 'assess-case',
id,
revision,
summary,
rules: ['Respect other players'],
findings: [{ category: 'harassment', finding, detail: summary }]
});
const queue = moderationOverview('first', moderationQuery(new URLSearchParams('view=cases')));
expect(queue.cases[0].assessment).toMatchObject({
summary: 'Full excerpt confirms targeted harassment',
findings: [{ category: 'harassment', finding: 'substantiated' }]
});
expect(moderationCaseDetail('first', id).assessmentHistory).toHaveLength(2);
});
@@ -0,0 +1,317 @@
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'moderator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
const create = (overrides: Record<string, unknown> = {}) =>
mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review incident',
...overrides
});
const link = (id: string, revision: number, relatedCaseId: string, relation = 'same_incident') => ({
op: 'link-case',
id,
revision,
relatedCaseId,
relation,
reason: 'Evidence connects these reviews.'
});
const unlink = (id: string, revision: number, relationshipId: string) => ({
op: 'unlink-case',
id,
revision,
relationshipId,
reason: 'The investigations concern different incidents.'
});
async function linkCurrent(id: string, related: string, relation: string) {
return mutate(link(id, (await detail(id)).case.revision, related, relation));
}
it('records symmetric incident links once, preserving both players and advancing both case revisions', async () => {
const a = await create();
const b = await create({ game: 't6', guid: '456', title: 'Connected player' });
const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() };
await mutate(request);
await mutate(request);
const first = await detail(a.id);
const second = await detail(b.id, 'super_viewer');
expect(first.case).toMatchObject({ revision: 2, game: 't5', guid: '123', status: 'open' });
expect(second.case).toMatchObject({ revision: 2, game: 't6', guid: '456', status: 'open' });
expect(first.relatedCases).toMatchObject([
{ relation: 'same_incident', case: { id: b.id, title: 'Connected player' } }
]);
expect(second.relatedCases).toMatchObject([
{ id: first.relatedCases![0].id, case: { id: a.id } }
]);
expect(new Set([first.relatedCases![0].direction, second.relatedCases![0].direction])).toEqual(
new Set(['incoming', 'outgoing'])
);
await mutate(link(b.id, 2, a.id));
expect(await detail(a.id)).toEqual(first);
expect(await detail(b.id)).toEqual(second);
expect(
handle.prepare('SELECT case_id,target_case_id FROM moderation_case_relationships').all()
).toEqual([{ case_id: [a.id, b.id].sort()[0], target_case_id: [a.id, b.id].sort()[1] }]);
expect(JSON.parse(first.events[0].detail)).toMatchObject({
relation: 'same_incident',
relatedCaseId: b.id,
reason: request.reason
});
await expect(
mutate({ op: 'case-note', id: b.id, revision: 1, text: 'Stale note' })
).rejects.toMatchObject({ status: 409 });
});
it('retains directed appeal and duplicate relationships while rejecting cycles within each relation', async () => {
const a = await create();
const b = await create();
const c = await create();
await linkCurrent(a.id, b.id, 'duplicate_of');
await linkCurrent(b.id, c.id, 'duplicate_of');
await expect(linkCurrent(c.id, a.id, 'duplicate_of')).rejects.toThrow('cycle');
await expect(linkCurrent(b.id, a.id, 'duplicate_of')).rejects.toThrow('cycle');
await linkCurrent(b.id, a.id, 'appeal_of');
await expect(linkCurrent(a.id, b.id, 'appeal_of')).rejects.toThrow('cycle');
expect((await detail(a.id)).relatedCases).toEqual(
expect.arrayContaining([
expect.objectContaining({
relation: 'duplicate_of',
direction: 'outgoing',
case: expect.objectContaining({ id: b.id })
}),
expect.objectContaining({
relation: 'appeal_of',
direction: 'incoming',
case: expect.objectContaining({ id: b.id })
})
])
);
const relationship = (await detail(b.id)).relatedCases!.find(
(entry) => entry.relation === 'duplicate_of' && entry.direction === 'outgoing'
)!;
await mutate(unlink(b.id, (await detail(b.id)).case.revision, relationship.id));
await linkCurrent(c.id, a.id, 'duplicate_of');
expect((await detail(c.id)).relatedCases).toMatchObject([
{ relation: 'duplicate_of', direction: 'outgoing', case: { id: a.id } }
]);
});
it('enforces case existence, staff access, revisions, self-link rejection and subject boundaries', async () => {
const a = await create();
const b = await create({ guid: '456' });
const c = await create({ game: 't6' });
for (const actor of ['viewer', 'super_viewer'])
await expect(mutate(link(a.id, 1, b.id), actor)).rejects.toMatchObject({ status: 403 });
await expect(mutate(link(a.id, 2, b.id))).rejects.toMatchObject({ status: 409 });
await expect(mutate(link(a.id, 1, 'missing'))).rejects.toMatchObject({ status: 404 });
await expect(mutate(link(a.id, 1, a.id))).rejects.toThrow('itself');
await expect(mutate(link(a.id, 1, b.id, 'unknown'))).rejects.toMatchObject({ status: 400 });
for (const relation of ['duplicate_of', 'appeal_of'])
for (const other of [b.id, c.id])
await expect(mutate(link(a.id, 1, other, relation))).rejects.toThrow(
'same game and player'
);
expect((await detail(a.id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]);
await mutate({
op: 'resolve-case',
id: a.id,
revision: 1,
status: 'no_action',
reason: 'Review complete'
});
await mutate(link(a.id, 2, b.id));
expect((await detail(a.id)).case).toMatchObject({ status: 'no_action', revision: 3 });
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
});
it('removes relationships from either endpoint without erasing history and permits a fresh link later', async () => {
const a = await create();
const b = await create();
const unrelated = await create();
await mutate(link(a.id, 1, b.id, 'appeal_of'));
const relationship = (await detail(a.id)).relatedCases![0];
await expect(mutate(unlink(unrelated.id, 1, relationship.id))).rejects.toMatchObject({
status: 404
});
await expect(mutate(unlink(b.id, 1, relationship.id))).rejects.toMatchObject({ status: 409 });
await mutate(unlink(b.id, 2, relationship.id));
for (const item of [a, b]) {
const data = await detail(item.id);
expect(data.case.revision).toBe(3);
expect(data.relatedCases).toEqual([]);
expect(data.events.map((entry) => entry.action)).toEqual([
'case.unlinked',
'case.linked',
'case.opened'
]);
}
expect(
handle
.prepare('SELECT removed_at FROM moderation_case_relationships WHERE id=?')
.get(relationship.id)
).toEqual({ removed_at: clock });
await mutate(unlink(a.id, 3, relationship.id));
expect((await detail(a.id)).case.revision).toBe(3);
await mutate(link(a.id, 3, b.id, 'appeal_of'));
expect((await detail(a.id)).relatedCases![0].id).not.toBe(relationship.id);
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_relationships').get()
).toEqual({ count: 2 });
});
it('rolls back both case revisions, relationship changes and receipts when either audit fails', async () => {
const a = await create();
const b = await create();
handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events
WHEN NEW.action IN ('case.linked','case.unlinked') BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() };
await expect(mutate(request)).rejects.toThrow('audit unavailable');
expect((await detail(a.id)).case.revision).toBe(1);
expect((await detail(b.id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
handle.exec('DROP TRIGGER fail_relationship_audit');
await mutate(request);
const relationship = (await detail(a.id)).relatedCases![0];
handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.unlinked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
await expect(mutate(unlink(a.id, 2, relationship.id))).rejects.toThrow('audit unavailable');
expect((await detail(a.id)).case.revision).toBe(2);
expect((await detail(b.id)).case.revision).toBe(2);
expect(handle.prepare('SELECT removed_at FROM moderation_case_relationships').get()).toEqual({
removed_at: null
});
});
it('suggests recent same-player cases and treats valid shared archived matches only as review hints', async () => {
const a = await create();
const shared = await create();
const different = await create();
const malformed = await create();
const wrongSubject = await create();
await create({ guid: '456' });
await create({ game: 't6' });
const snapshot = (matchId: number, guid = '123') => ({
matchId,
match: { game: 't5', server: 'removed-server', started_at: clock },
entry: { type: 'message', message: { guid, message: 'Preserved evidence' } }
});
const add = (caseId: string, value: string) =>
handle
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(
randomUUID(),
caseId,
'Preserved evidence',
'Archived chat',
value,
'moderator',
'moderator',
clock
);
add(a.id, JSON.stringify(snapshot(12)));
add(shared.id, JSON.stringify(snapshot(12)));
add(different.id, JSON.stringify(snapshot(13)));
add(malformed.id, '{broken JSON');
add(malformed.id, JSON.stringify({ matchId: 12 }));
add(wrongSubject.id, JSON.stringify(snapshot(12, '456')));
const candidates = (await detail(a.id)).candidateCases!;
expect(candidates).toHaveLength(4);
expect(candidates.find((item) => item.id === shared.id)?.sharedMatch).toBe(true);
expect(
candidates.filter((item) => item.id !== shared.id).every((item) => !item.sharedMatch)
).toBe(true);
expect((await detail(a.id)).relatedCases).toEqual([]);
for (let index = 0; index < 30; index++) {
vi.setSystemTime(new Date(Date.parse(clock) + (index + 1) * 60_000));
await create({ title: `Later case ${index}` });
}
const recent = (await detail(a.id)).candidateCases!;
expect(recent).toHaveLength(25);
expect(recent[0].title).toBe('Later case 29');
expect(recent[24].title).toBe('Later case 5');
});
it('upgrades relationship storage with foreign keys, symmetric identity and active-only uniqueness', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 32));
const insertCase =
legacy.prepare(`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES(?,'t5','123','Existing case','open','former','Former staff',?,?)`);
insertCase.run('a', clock, clock);
insertCase.run('b', clock, clock);
const before = legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toMatchObject(
before
);
const insert = legacy.prepare(
'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)'
);
insert.run('first', 'a', 'b', 'same_incident', 'former', 'Former staff', clock);
expect(() =>
insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock)
).toThrow('UNIQUE');
expect(() =>
insert.run('reverse', 'b', 'a', 'same_incident', 'former', 'Former staff', clock)
).toThrow('CHECK');
expect(() =>
insert.run('self', 'a', 'a', 'appeal_of', 'former', 'Former staff', clock)
).toThrow('CHECK');
expect(() =>
insert.run('missing', 'a', 'missing', 'appeal_of', 'former', 'Former staff', clock)
).toThrow('FOREIGN KEY');
legacy
.prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?')
.run(clock, 'first');
insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock);
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});
+78
View File
@@ -453,3 +453,81 @@ it('keeps decision history writable when a case has reached its evidence limit',
expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 }); expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 });
expect((await detail(id)).case.evidence).toHaveLength(102); expect((await detail(id)).case.evidence).toHaveLength(102);
}); });
it('preserves all participants and selected authors, distinguishes repeated messages, and deduplicates attachments', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.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;
};
const insert = handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)');
for (let i = 0; i < 22; i++) {
const subject = i > 0 && i < 20;
insert.run(
matchId,
subject ? 'repeat' : `context-${i}`,
subject ? i : 1,
JSON.stringify({
type: 'message',
context: { realTime: null, round: null, matchElapsedMs: null },
message: {
time: '1:01',
guid: subject ? '123' : '456',
player: subject ? 'Subject' : 'Witness',
slot: subject ? 1 : 2,
message: subject ? 'Repeated message' : `Context ${i}`
}
})
);
}
const entries = matchChatTimeline(matchId);
expect(new Set(entries.map((entry) => entry.messageId)).size).toBe(22);
const { id } = await create();
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: entries.slice(1, 20).map((entry) => entry.messageId) }
});
let data = await detail(id);
expect(data.case.evidence).toHaveLength(1);
const snapshot = JSON.parse(data.case.evidence[0].snapshot!);
expect(snapshot.entries).toHaveLength(22);
expect(snapshot.selectedMessageIds).toHaveLength(19);
expect(snapshot.highlightedMessageIds).toHaveLength(19);
expect(snapshot.entries[0].message.player).toBe('Witness');
await mutate({
op: 'attach-evidence',
id,
revision: 2,
evidence: { matchId, message: 'Repeated message' }
});
data = await detail(id);
expect(data.case.revision).toBe(2);
expect(data.case.evidence).toHaveLength(1);
await expect(
mutate({
op: 'attach-evidence',
id,
revision: 2,
evidence: { matchId, messageIds: ['forged'] }
})
).rejects.toThrow('identifiers');
const legacy = await create();
await mutate({
op: 'attach-evidence',
id: legacy.id,
revision: 1,
evidence: { matchId, message: 'Repeated message' }
});
expect((await detail(legacy.id)).case.evidence).toHaveLength(19);
handle.prepare('DELETE FROM leaderboard_matches').run();
expect(JSON.parse((await detail(id)).case.evidence[0].snapshot!).entries).toHaveLength(22);
});
+239
View File
@@ -0,0 +1,239 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../src/lib/auth.js';
import {
matchApiParams,
matchHistoryHref,
readMatchQuery
} from '../../src/lib/match-history-query.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const server = 't5_zombie_theater_1';
const clock = '2026-10-17T12:00:00.000Z';
const end = '2026-10-17T12:01:00.000Z';
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
handle
.prepare("INSERT INTO servers(name,game,map) VALUES(?,'t5','zombie_theater')")
.run(server);
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', '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);
handle
.prepare(
"INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES('t5','123','Player',?,?)"
)
.run(clock, end);
const insert =
handle.prepare(`INSERT INTO leaderboard_matches(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_observed,highest_round,last_event,last_fingerprint)
VALUES(?,?,'t5','zombie_theater','t5:zombie_theater','Kino Der Toten',?,?,?,?,1,?,'game_end','test')`);
for (const [id, status, round] of [
[1, 'completed', 1],
[2, 'interrupted', 1],
[3, 'active', 0],
[4, 'end-report-missing', 2],
[5, 'completed', 8],
[6, 'completed', 1]
] as const) {
insert.run(id, id === 6 ? 'other-server' : server, clock, end, end, status, round);
handle
.prepare(
`INSERT INTO leaderboard_match_players(match_id,guid,highest_round,has_stats,completed,first_seen_at,last_seen_at)
VALUES(?,'123',?,1,?,?,?)`
)
.run(id, round, status === 'completed' ? 1 : 0, clock, end);
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
id,
`message-${id}`,
1,
JSON.stringify({
type: 'message',
message: {
time: '1:00',
guid: '123',
slot: 1,
player: 'Player',
message: `Message from match ${id}`
},
context: { realTime: null, matchElapsedMs: null, round: null }
})
);
}
});
afterEach(async () => fixture.cleanup());
async function user(role: string): Promise<AuthUser> {
const { getUser } = await import('../../src/lib/server/auth/index.js');
return getUser(role)!;
}
async function api(actor: AuthUser | null, scoped = false, archive = '1') {
const url = new URL(`http://localhost/api/matches?archive=${archive}`);
if (scoped) {
const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
return GET({ url, params: { name: server }, locals: { user: actor } });
}
const { GET } = await import('../../src/routes/api/matches/+server.js');
return GET({ url, locals: { user: actor } });
}
async function page(
actor: AuthUser | null,
surface: 'list' | 'detail' | 'instance',
id = '1',
archive = '1'
) {
const url = new URL(`http://localhost/matches/${id}?matchArchive=${archive}`);
const setHeaders = vi.fn();
let result;
if (surface === 'list') {
const { load } = await import('../../src/routes/matches/+page.server.js');
result = load({ url, locals: { user: actor }, setHeaders });
} else if (surface === 'detail') {
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
result = load({ url, params: { id }, locals: { user: actor }, setHeaders });
} else {
const { load } = await import('../../src/routes/servers/[name]/+page.server.js');
result = load({ url, params: { name: server }, locals: { user: actor }, setHeaders });
}
return { result, setHeaders };
}
it('makes archive selection explicit, strictly parses it and preserves it through namespaced navigation', () => {
expect(readMatchQuery(new URLSearchParams()).archive).toBe(false);
expect(readMatchQuery(new URLSearchParams('archive=0')).archive).toBe(false);
expect(readMatchQuery(new URLSearchParams('archive=1')).archive).toBe(true);
for (const value of ['true', 'yes', '2'])
expect(() => readMatchQuery(new URLSearchParams(`archive=${value}`))).toThrow(
'Invalid match history filters'
);
const url = new URL('http://localhost/matches?matchPage=3&matchStatus=interrupted');
const href = matchHistoryHref(url, { archive: '1' });
expect(href).toContain('matchArchive=1');
expect(href).not.toContain('matchPage');
expect(matchApiParams(new URL(href, url)).get('archive')).toBe('1');
});
it('exposes saved round-one, interrupted, active and missing-end chat only in expanded queries without changing gameplay qualification', async () => {
const { getMatchDetail, serverMatchHistory } =
await import('../../src/lib/server/fleet/match-history.js');
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const publicBefore = getLeaderboard();
expect(publicBefore.entries[0].gamesPlayed).toBe(1);
expect(serverMatchHistory().total).toBe(2);
const archive = readMatchQuery(new URLSearchParams('archive=1'));
expect(serverMatchHistory(undefined, 25, archive).total).toBe(6);
expect(serverMatchHistory(server, 25, archive).total).toBe(5);
expect(
serverMatchHistory(
server,
25,
readMatchQuery(new URLSearchParams('archive=1&status=interrupted'))
).matches.map((item) => item.id)
).toEqual([2]);
for (const id of [1, 2, 3, 4, 6]) {
expect(getMatchDetail(id)).toBeNull();
expect(getMatchDetail(id, true)?.chat).toMatchObject([
{ message: { message: `Message from match ${id}` }, messageId: `${id}:message-${id}:1` }
]);
}
for (const id of [2, 3, 4])
expect(getMatchDetail(id, true)).toMatchObject({
finalRound: null,
durationMs: null,
endedAt: null,
endPlayers: null,
players: [{ presentAtEnd: false }]
});
expect(getMatchDetail(5)).toMatchObject({ status: 'completed', finalRound: 8 });
expect(getLeaderboard()).toEqual(publicBefore);
handle.prepare('DELETE FROM servers WHERE name=?').run(server);
expect(getMatchDetail(2, true)?.chat).toHaveLength(1);
});
it.each(['admin', 'moderator', 'super_viewer'])(
'allows %s archive access with private responses on every API and page surface',
async (role) => {
const actor = await user(role);
for (const scoped of [false, true]) {
const response = await api(actor, scoped);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect((await response.json()).total).toBe(scoped ? 5 : 6);
}
for (const surface of ['list', 'detail', 'instance'] as const) {
const loaded = await page(actor, surface);
expect(loaded.setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
if (surface === 'detail')
expect(loaded.result).toMatchObject({
match: { id: 1, status: 'completed', finalRound: 1 }
});
}
}
);
it.each(['operator', 'viewer', null])(
'rejects %s expanded access before revealing match existence',
async (role) => {
const actor = role ? await user(role) : null;
for (const scoped of [false, true]) {
const response = await api(actor, scoped);
expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
}
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
await expect(page(actor, 'detail', '999')).rejects.toMatchObject({ status: 403 });
expect((await api(actor, false, '0')).status).toBe(200);
await expect(page(actor, 'detail', '1', '0')).rejects.toMatchObject({ status: 404 });
expect((await page(actor, 'detail', '5', '0')).result).toMatchObject({ match: { id: 5 } });
}
);
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
'revalidates stale moderator locals after %s',
async (change) => {
const actor = await user('moderator');
handle.prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
expect((await api(actor)).status).toBe(403);
expect((await api(actor, true)).status).toBe(403);
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
}
);
it('returns validation and not-found errors without silently falling back to the standard history', async () => {
const actor = await user('moderator');
expect((await api(actor, false, 'true')).status).toBe(400);
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface, '1', 'true')).rejects.toMatchObject({ status: 400 });
for (const id of ['0', '01', '-1', '1.5', '999'])
await expect(page(actor, 'detail', id)).rejects.toMatchObject({ status: 404 });
});
it('attaches interrupted-match archive evidence and keeps the snapshot after source cleanup', async () => {
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
const entry = getMatchDetail(2, true)!.chat[0];
const { id } = mutateModeration('moderator', {
op: 'create-case',
game: 't5',
guid: '123',
title: 'Interrupted match report',
evidence: { matchId: 2, messageIds: [entry.messageId] },
idempotencyKey: randomUUID()
});
handle.prepare('DELETE FROM leaderboard_matches WHERE id=2').run();
const evidence = moderationCaseDetail('moderator', id).case.evidence[0];
expect(JSON.parse(evidence.snapshot!).entry.message.message).toBe('Message from match 2');
});
+246
View File
@@ -0,0 +1,246 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
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();
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('moderator','moderator','moderator','unused','moderator',0,0)`
)
.run();
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>) {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration('moderator', { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string) {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail('moderator', id);
}
async function create(overrides: Record<string, unknown> = {}) {
return mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review archived conversation',
...overrides
});
}
async function archive(messages: Array<{ guid?: string; message: string; channel?: 'team' }>) {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js');
saveTelemetry({
server,
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Subject' }],
receivedAt: clock
});
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
id: number;
};
const insert = handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)');
for (const [index, message] of messages.entries())
insert.run(
matchId,
`message-${index}`,
1,
JSON.stringify({
type: 'message',
context: { realTime: null, matchElapsedMs: null, round: null },
message: {
time: '1:01',
guid: message.guid ?? '123',
slot: message.guid ? 2 : 1,
player: message.guid ? 'Witness' : 'Subject',
message: message.message,
...(message.channel ? { channel: message.channel } : {})
}
})
);
return { matchId, entries: matchChatTimeline(matchId) };
}
it('rejects a witness-only selection, foreign archive IDs and another game without changing a case', async () => {
const { matchId, entries } = await archive([
{ message: 'Subject statement' },
{ guid: '456', message: 'Witness reply' }
]);
const { id } = await create();
const before = await detail(id);
for (const messageIds of [
[entries[1].messageId],
[`${matchId + 1}:message-0:1`],
[entries[0].messageId, `${matchId + 1}:message-1:1`]
])
await expect(
mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, messageIds } })
).rejects.toMatchObject({ status: expect.any(Number) });
expect(await detail(id)).toEqual(before);
const other = await create({ game: 't6' });
await expect(
mutate({
op: 'attach-evidence',
id: other.id,
revision: 1,
evidence: { matchId, messageIds: [entries[0].messageId] }
})
).rejects.toMatchObject({ status: 404 });
expect((await detail(other.id)).case).toMatchObject({ revision: 1, evidence: [] });
});
it('allows a new witness reply alongside an attached subject and deduplicates later selections', async () => {
const { matchId, entries } = await archive([
{ message: 'Subject statement' },
{ guid: '456', message: 'Witness reply' }
]);
const [subjectId, witnessId] = entries.map((entry) => entry.messageId!);
const { id } = await create();
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: [subjectId], contextBefore: 0, contextAfter: 0 }
});
const request = {
op: 'attach-evidence',
id,
revision: 2,
evidence: {
matchId,
messageIds: [witnessId, subjectId, witnessId],
contextBefore: 0,
contextAfter: 0
},
idempotencyKey: randomUUID()
};
await mutate(request);
await mutate(request);
const attached = await detail(id);
expect(attached.case.revision).toBe(3);
expect(attached.case.evidence).toHaveLength(2);
const snapshot = JSON.parse(attached.case.evidence[1].snapshot!);
expect(snapshot.selectedMessageIds).toEqual([witnessId]);
expect(snapshot.highlightedMessageIds).toEqual([subjectId]);
expect(snapshot.entries.map((entry: { messageId: string }) => entry.messageId)).toEqual([
subjectId,
witnessId
]);
expect(snapshot.entries[1].message.guid).toBe('456');
await mutate({ ...request, revision: 3, idempotencyKey: randomUUID() });
expect(await detail(id)).toEqual(attached);
});
it('preserves team chat separately when matching evidence saved before message identifiers', async () => {
const { matchId, entries } = await archive([
{ message: 'Same words', channel: 'team' },
{ message: 'Same words' }
]);
const { id } = await create({
evidence: { matchId, messageIds: [entries[1].messageId] }
});
// A legacy public-chat snapshot has no selected IDs or channel field.
handle
.prepare('UPDATE moderation_evidence SET snapshot=? WHERE case_id=?')
.run(JSON.stringify({ matchId, entry: entries[1] }), id);
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: [entries[0].messageId] }
});
const attached = await detail(id);
expect(attached.case.evidence).toHaveLength(2);
const snapshot = JSON.parse(attached.case.evidence[1].snapshot!);
expect(snapshot.entry.message.channel).toBe('team');
expect(snapshot.selectedMessageIds).toEqual([entries[0].messageId]);
});
it('validates selection and context bounds and rolls back opening a case with invalid evidence', async () => {
const { matchId, entries } = await archive([{ message: 'Subject statement' }]);
const messageId = entries[0].messageId!;
const invalid = [
{ messageIds: [] },
{ messageIds: Array.from({ length: 101 }, () => messageId) },
{ messageIds: [messageId], contextBefore: -1 },
{ messageIds: [messageId], contextAfter: 21 },
{ messageIds: [messageId], contextBefore: 1.5 },
{ messageIds: [messageId], contextAfter: '3' }
];
for (const evidence of invalid)
await expect(create({ evidence: { matchId, ...evidence } })).rejects.toMatchObject({
status: 400
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_cases').get()).toEqual({
count: 0
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 0
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_requests').get()).toEqual({
count: 0
});
});
it('rolls back a legacy bulk attachment if the case reaches its evidence limit partway through', async () => {
const { matchId } = await archive([
{ message: 'Repeated statement' },
{ message: 'Repeated statement' },
{ message: 'Repeated statement' }
]);
const { id } = await create();
const insert = handle.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)');
for (let index = 0; index < 99; index++)
insert.run(
randomUUID(),
id,
`Note ${index}`,
'Staff note',
null,
'moderator',
'moderator',
clock
);
const before = await detail(id);
const idempotencyKey = randomUUID();
await expect(
mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, message: 'Repeated statement' },
idempotencyKey
})
).rejects.toMatchObject({ status: 409 });
expect(await detail(id)).toEqual(before);
expect(
handle
.prepare('SELECT COUNT(*) AS count FROM moderation_requests WHERE request_key=?')
.get(idempotencyKey)
).toEqual({ count: 0 });
});
+395
View File
@@ -0,0 +1,395 @@
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
const future = '2026-10-17T13:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const [id, role] of [
['creator', 'moderator'],
['owner', 'moderator'],
['next', 'moderator'],
['admin', 'admin'],
['viewer', 'viewer'],
['reader', 'super_viewer']
])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(id, id, id, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'creator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'creator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
async function overview(actor = 'creator', params = '') {
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
return moderationOverview(actor, moderationQuery(new URLSearchParams(`view=cases&${params}`)));
}
const create = (overrides: Record<string, unknown> = {}) =>
mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review incident',
...overrides
});
const schedule = (id: string, revision: number, reviewAt: unknown = future) => ({
op: 'schedule-case-review',
id,
revision,
reviewAt,
reason: 'Review the case after the restriction ends.'
});
const restriction = (overrides: Record<string, unknown> = {}) =>
mutate(
{
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'mute',
scope: 'game',
durationMinutes: 60,
reason: 'Earlier moderation action',
...overrides
},
'admin'
);
it('schedules canonical dates, preserves them through closure and reopening, and clears them with an audit', async () => {
const { id } = await create();
for (const value of [
'',
'2026-10-17',
'2026-02-30T12:00:00.000Z',
'2026-10-17T12:00:00Z',
'2026-10-17T08:00:00.000-04:00',
12,
'+010000-01-01T00:00:00.000Z'
])
await expect(mutate(schedule(id, 1, value))).rejects.toMatchObject({ status: 400 });
await expect(mutate({ ...schedule(id, 1), restrictionId: 'another' })).rejects.toMatchObject({
status: 400
});
for (const actor of ['viewer', 'reader'])
await expect(mutate(schedule(id, 1), actor)).rejects.toMatchObject({ status: 403 });
await mutate(schedule(id, 1));
await expect(mutate(schedule(id, 1))).rejects.toMatchObject({ status: 409 });
const first = await detail(id);
expect(first.case).toMatchObject({
revision: 2,
reviewAt: future,
reviewSanctionId: null,
reviewReason: schedule(id, 1).reason
});
await mutate(schedule(id, 2));
expect(await detail(id)).toEqual(first);
await mutate({
op: 'resolve-case',
id,
revision: 2,
status: 'no_action',
reason: 'Review complete for now'
});
expect((await detail(id)).case.reviewAt).toBe(future);
await mutate({ op: 'reopen-case', id, revision: 3, reason: 'New context arrived' });
expect((await detail(id)).case.reviewAt).toBe(future);
await mutate(schedule(id, 4, null));
const cleared = await detail(id);
expect(cleared.case).toMatchObject({ revision: 5, reviewAt: null, reviewSanctionId: null });
expect(JSON.parse(cleared.events[0].detail)).toEqual({
reviewAt: null,
reason: schedule(id, 1).reason,
restrictionId: null
});
});
it('derives dates only from linked finite mutes and supports already expired restrictions without reissuing them', async () => {
const { id } = await create();
const mute = await restriction({ caseId: id, caseRevision: 1 });
const unlinked = await restriction();
const permanent = await restriction({ caseId: id, caseRevision: 2, durationMinutes: null });
const ban = await restriction({ caseId: id, caseRevision: 3, kind: 'ban' });
const other = await restriction({ guid: '456' });
const input = (restrictionId: string) => ({
op: 'schedule-case-review',
id,
revision: 4,
restrictionId,
reason: 'Check after mute expires'
});
await expect(mutate(input(unlinked.id))).rejects.toThrow('Link the mute');
for (const target of [permanent.id, ban.id])
await expect(mutate(input(target))).rejects.toThrow('recorded expiry');
await expect(mutate(input(other.id))).rejects.toMatchObject({ status: 404 });
const before = handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all();
const policy = handle.prepare('SELECT * FROM moderation_policy').all();
vi.setSystemTime(new Date('2026-10-17T14:00:00.000Z'));
await mutate(input(mute.id));
expect((await detail(id)).case).toMatchObject({
reviewAt: future,
reviewSanctionId: mute.id,
revision: 5
});
expect(handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all()).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_policy').all()).toEqual(policy);
});
it('prioritizes overdue closed cases and recalculates due and scheduled queues using the current clock', async () => {
const closed = await create({ title: 'Closed follow-up' });
const active = await create({ title: 'Urgent active case', priority: 'urgent' });
const upcoming = await create({ title: 'Upcoming review' });
await mutate({
op: 'resolve-case',
id: closed.id,
revision: 1,
status: 'no_action',
reason: 'Initial review complete'
});
await mutate(schedule(closed.id, 2, clock));
await mutate(schedule(upcoming.id, 1, future));
expect((await overview()).cases.map((item) => item.id)).toEqual([
closed.id,
active.id,
upcoming.id
]);
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
closed.id
]);
expect((await overview('creator', 'review=scheduled')).total).toBe(2);
expect((await overview('creator', 'review=invalid')).query.review).toBe('');
vi.setSystemTime(new Date(future));
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
closed.id,
upcoming.id
]);
expect((await overview()).cases[0].status).toBe('no_action');
await mutate(schedule(closed.id, 3, null));
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
upcoming.id
]);
});
it('notifies distinct active assignees and the creator of assignment changes, excluding the acting staff member', async () => {
const request = {
op: 'create-case',
game: 't5',
guid: '123',
title: 'Assigned review',
assigneeId: 'owner',
idempotencyKey: randomUUID()
};
const { id } = await mutate(request);
await mutate(request);
expect((await detail(id, 'owner')).notifications).toMatchObject([{ kind: 'assignment' }]);
expect((await detail(id, 'creator')).notifications).toEqual([]);
await mutate({ op: 'update-case', id, revision: 1, assigneeId: 'next' }, 'admin');
const recipients = handle
.prepare(
'SELECT user_id,COUNT(*) AS count FROM moderation_case_notifications GROUP BY user_id ORDER BY user_id'
)
.all();
expect(recipients).toEqual([
{ user_id: 'creator', count: 1 },
{ user_id: 'next', count: 1 },
{ user_id: 'owner', count: 2 }
]);
expect((await overview('admin')).unreadCount).toBe(0);
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='creator'").run();
handle.prepare("UPDATE auth_users SET role='viewer' WHERE id='next'").run();
await mutate({ op: 'update-case', id, revision: 2, assigneeId: 'owner' }, 'admin');
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_notifications').get()
).toEqual({ count: 5 });
await expect(
mutate({ op: 'update-case', id, revision: 3, assigneeId: 'owner' }, 'admin')
).rejects.toThrow('Change the case');
});
it('notifies once per fresh evidence batch, with no notifications for duplicate attachments or retries', async () => {
const { id } = await create({ assigneeId: 'owner' });
const matchId = Number(
handle
.prepare(
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES('removed-server','t5','map','map','Map',?,?,'game_start','start')`
)
.run(clock, clock).lastInsertRowid
);
for (let occurrence = 1; occurrence <= 19; occurrence++)
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
matchId,
'repeat',
occurrence,
JSON.stringify({
type: 'message',
message: {
time: '1:00',
guid: '123',
player: 'Player',
message: 'Repeated message'
},
context: { realTime: null, round: null, matchElapsedMs: null }
})
);
const request = {
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, message: 'Repeated message' },
idempotencyKey: randomUUID()
};
await mutate(request, 'admin');
await mutate(request, 'admin');
await mutate({ ...request, revision: 2, idempotencyKey: randomUUID() }, 'admin');
expect((await detail(id)).case.evidence).toHaveLength(19);
expect(
handle
.prepare(
"SELECT user_id,kind FROM moderation_case_notifications WHERE kind='evidence' ORDER BY user_id"
)
.all()
).toEqual([
{ user_id: 'creator', kind: 'evidence' },
{ user_id: 'owner', kind: 'evidence' }
]);
await mutate({ op: 'case-note', id, revision: 2, text: 'More context' }, 'owner');
expect((await detail(id, 'owner')).case.unreadUpdates).toBe(2);
expect((await detail(id, 'creator')).case.unreadUpdates).toBe(2);
expect((await overview('owner', 'review=updates')).cases.map((item) => item.id)).toEqual([id]);
expect((await overview('admin', 'review=updates')).total).toBe(0);
});
it('marks only the reader’s seen case notifications and preserves newer arrivals and other cases', async () => {
const { id } = await create({ assigneeId: 'owner' });
const other = await create({ assigneeId: 'owner' });
await mutate({ op: 'case-note', id, text: 'First new evidence' }, 'admin');
const seen = await detail(id, 'owner');
const throughId = Math.max(...seen.notifications!.map((entry) => entry.id));
await mutate({ op: 'case-note', id, text: 'Later evidence' }, 'admin');
const revision = (await detail(id)).case.revision;
const read = { op: 'read-case-updates', id, throughId, idempotencyKey: randomUUID() };
await mutate(read, 'owner');
await mutate(read, 'owner');
const data = await detail(id, 'owner');
expect(data.case).toMatchObject({ revision, unreadUpdates: 1 });
expect(data.notifications!.filter((entry) => !entry.readAt)).toHaveLength(1);
expect(data.notifications![0].id).toBeGreaterThan(throughId);
expect((await detail(id, 'creator')).case.unreadUpdates).toBe(2);
expect((await detail(other.id, 'owner')).case.unreadUpdates).toBe(1);
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, '2'])
await expect(
mutate({ ...read, throughId: invalid, idempotencyKey: randomUUID() }, 'owner')
).rejects.toMatchObject({ status: 400 });
await expect(mutate({ ...read, idempotencyKey: randomUUID() }, 'reader')).rejects.toMatchObject(
{ status: 403 }
);
});
it('returns bounded notification lists alongside complete personal unread counts', async () => {
const { id } = await create();
for (let index = 0; index < 30; index++)
handle
.prepare(
"INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('owner',?,'evidence',?)"
)
.run(id, clock);
const data = await detail(id, 'owner');
const queue = await overview('owner');
expect(data.case.unreadUpdates).toBe(30);
expect(data.notifications).toHaveLength(25);
expect(queue.unreadCount).toBe(30);
expect(queue.notifications).toHaveLength(25);
expect(queue.notifications![0].id).toBeGreaterThan(queue.notifications![24].id);
expect((await detail(id)).notifications).toEqual([]);
expect((await overview('creator')).notifications).toEqual([]);
});
it('rolls back schedules and evidence if their audit or notification write fails', async () => {
const { id } = await create({ assigneeId: 'owner' });
const before = await detail(id);
handle.exec(`CREATE TRIGGER fail_review_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.review_scheduled' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
await expect(mutate(schedule(id, 1))).rejects.toThrow('audit unavailable');
expect(await detail(id)).toEqual(before);
handle.exec('DROP TRIGGER fail_review_audit');
handle.exec(`CREATE TRIGGER fail_notification BEFORE INSERT ON moderation_case_notifications
BEGIN SELECT RAISE(ABORT,'notification unavailable'); END`);
const request = { op: 'case-note', id, text: 'New evidence', idempotencyKey: randomUUID() };
await expect(mutate(request, 'admin')).rejects.toThrow('notification unavailable');
expect(await detail(id)).toEqual(before);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
});
it('upgrades existing cases with unscheduled defaults and durable personal notification constraints', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 33));
legacy
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('case','t5','123','Existing case','open','former','Former staff',?,?)`
)
.run(clock, clock);
legacy
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('owner','owner','Owner','unused','moderator',0,0)`
)
.run();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(
legacy
.prepare(
'SELECT id,revision,review_at,review_reason,review_sanction_id FROM moderation_cases'
)
.get()
).toEqual({
id: 'case',
revision: 1,
review_at: null,
review_reason: '',
review_sanction_id: null
});
const insert = legacy.prepare(
'INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES(?,?,?,?)'
);
insert.run('owner', 'case', 'evidence', clock);
expect(() => insert.run('missing', 'case', 'evidence', clock)).toThrow('FOREIGN KEY');
expect(() => insert.run('owner', 'missing', 'evidence', clock)).toThrow('FOREIGN KEY');
expect(() => insert.run('owner', 'case', 'unknown', clock)).toThrow('CHECK');
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});
+107 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { ContextualChatEntry } from '../../src/lib/game-chat.js'; import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
import type { TailResult } from '../../src/lib/server/fleet/logs.js'; import type { LogChunk, TailResult } from '../../src/lib/server/fleet/logs.js';
const SERVER = 't5_zombie_theater_1'; const SERVER = 't5_zombie_theater_1';
const OTHER_SERVER = 't6_zombie_town_1'; const OTHER_SERVER = 't6_zombie_town_1';
@@ -19,6 +19,7 @@ beforeEach(async () => {
db() db()
.prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'") .prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'")
.run(new Date(baseline - 120_000).toISOString()); .run(new Date(baseline - 120_000).toISOString());
db().prepare("UPDATE servers SET container_state='stopped' WHERE name=?").run(OTHER_SERVER);
}); });
afterEach(async () => { afterEach(async () => {
@@ -43,6 +44,25 @@ function messages(body: { entries: ContextualChatEntry[] }): string[] {
); );
} }
function chunk(content: string, offset: number, size: number, reset = false): LogChunk {
const mtime = new Date(baseline + 60_000).toISOString();
return {
contentBase64: Buffer.from(content).toString('base64'),
size,
mtime,
truncated: false,
reset,
cursor: {
fileId: 'chat-log',
offset,
size,
modified: mtime,
anchorLength: 0,
anchorHash: ''
}
};
}
function advance(milliseconds: number) { function advance(milliseconds: number) {
vi.setSystemTime(Date.now() + milliseconds); vi.setSystemTime(Date.now() + milliseconds);
} }
@@ -82,6 +102,92 @@ async function addInstance() {
} }
describe('server chat refresh sharing', () => { describe('server chat refresh sharing', () => {
it('picks up surviving logs once when collection starts on an idle server', async () => {
const s = await setup();
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['first']);
advance(60_000);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(1);
});
it('drains all remaining chunks after players leave before anchoring messages', async () => {
const s = await setup();
const lines = [
'0:30 say;abc;0;Player;first\n',
'0:40 say;abc;0;Player;middle\n',
'1:00 say;abc;0;Player;last\n'
];
const sizes = lines.map((line) => Buffer.byteLength(line));
const total = sizes.reduce((a, b) => a + b, 0);
s.read
.mockResolvedValueOnce(chunk(lines[0], sizes[0], total, true))
.mockResolvedValueOnce(chunk(lines[1], sizes[0] + sizes[1], total))
.mockResolvedValueOnce(chunk(lines[2], total, total));
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual([]);
s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual([]);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['first', 'middle', 'last']);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(3);
expect(s.read.mock.calls.map((call) => call[1]?.offset)).toEqual([
undefined,
sizes[0],
sizes[0] + sizes[1]
]);
});
it('bounds stalled partial-line retries and resumes collection when players return', async () => {
const s = await setup();
const partial = '1:00 say;abc;0;Player;incomplete';
const size = Buffer.byteLength(partial);
s.read
.mockResolvedValueOnce(chunk(partial, size, size, true))
.mockResolvedValue(chunk('', size, size));
await s.collectFleetChat();
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
for (let attempt = 0; attempt < 5; attempt++) {
advance(15_000);
await s.collectFleetChat();
}
expect(s.read).toHaveBeenCalledTimes(4);
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
s.read.mockResolvedValue(chunk('\n', size + 1, size + 1));
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['incomplete']);
expect(s.read).toHaveBeenCalledTimes(5);
});
it('stops idle reads for missing logs and bounds retries for read failures', async () => {
const s = await setup();
s.read.mockResolvedValueOnce(null);
await s.collectFleetChat();
advance(60_000);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(1);
// A new registration gets its own initial attempt and retry budget.
s.db
.prepare('UPDATE servers SET created_at=? WHERE name=?')
.run(new Date(baseline - 60_000).toISOString(), SERVER);
s.read.mockRejectedValue(new Error('Unavailable source'));
vi.spyOn(console, 'error').mockImplementation(() => {});
await s.collectFleetChat();
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
for (let attempt = 0; attempt < 5; attempt++) {
advance(15_000);
await s.collectFleetChat();
}
expect(s.read).toHaveBeenCalledTimes(4);
});
it('serves worker state to repeated viewers and still collects after players leave', async () => { it('serves worker state to repeated viewers and still collects after players leave', async () => {
const s = await setup(); const s = await setup();
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
+33 -8
View File
@@ -139,7 +139,7 @@ describe('server chat API', () => {
).toEqual(['first', 'retry me']); ).toEqual(['first', 'retry me']);
}); });
it('skips empty, stopped and offline servers and collects one last time after players leave', async () => { it('checks idle servers once, skips stopped and offline servers and collects after players leave', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { createHost } = await import('../../src/lib/server/hosts/index.js'); const { createHost } = await import('../../src/lib/server/hosts/index.js');
const logs = await import('../../src/lib/server/fleet/logs.js'); const logs = await import('../../src/lib/server/fleet/logs.js');
@@ -147,25 +147,27 @@ describe('server chat API', () => {
const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js'); const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js');
db().prepare("UPDATE servers SET last_players=0,container_state='running'").run(); db().prepare("UPDATE servers SET last_players=0,container_state='running'").run();
await collectFleetChat(); await collectFleetChat();
expect(read).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(2);
await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2);
db().prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); db().prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
await log(['60:50 say;abc;0;Player;playing'], 50); await log(['60:50 say;abc;0;Player;playing'], 50);
await collectFleetChat(); await collectFleetChat();
expect(read).toHaveBeenCalledTimes(1); expect(read).toHaveBeenCalledTimes(3);
db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER); db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
await log(['60:50 say;abc;0;Player;playing', '61:00 say;abc;0;Player;goodbye']); await log(['60:50 say;abc;0;Player;playing', '61:00 say;abc;0;Player;goodbye']);
expect((await (await get()).json()).entries).toHaveLength(1); expect((await (await get()).json()).entries).toHaveLength(1);
expect(read).toHaveBeenCalledTimes(1); expect(read).toHaveBeenCalledTimes(3);
await collectFleetChat(); await collectFleetChat();
await collectFleetChat(); await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenCalledTimes(4);
expect((await (await get()).json()).entries).toHaveLength(2); expect((await (await get()).json()).entries).toHaveLength(2);
expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenCalledTimes(4);
db() db()
.prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?") .prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?")
.run(SERVER); .run(SERVER);
await collectFleetChat(); await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenCalledTimes(4);
const host = createHost({ name: 'Offline' }); const host = createHost({ name: 'Offline' });
db().prepare("UPDATE servers SET container_state='running' WHERE name=?").run(SERVER); db().prepare("UPDATE servers SET container_state='running' WHERE name=?").run(SERVER);
db() db()
@@ -175,7 +177,30 @@ describe('server chat API', () => {
) )
.run(SERVER, host.id, at(0)); .run(SERVER, host.id, at(0));
await collectFleetChat(); await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenCalledTimes(4);
});
it('archives surviving idle match logs with distinct stable public and team message identities', async () => {
await report('game_start', 0);
await report('heartbeat', 20, 4);
await report('game_end', 60, 4);
await log(['60:30 say;abc;0;Player;hello', '60:30 sayteam;abc;0;Player;hello'], 30);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE servers SET last_players=0').run();
const { collectFleetChat, matchChatTimeline } =
await import('../../src/lib/server/fleet/chat.js');
await collectFleetChat();
const saved = matchChatTimeline(1);
expect(saved).toHaveLength(2);
expect(saved[0]).toMatchObject({ message: { message: 'hello' } });
expect(saved[0].type === 'message' && saved[0].message.channel).toBeUndefined();
expect(saved[1]).toMatchObject({ message: { message: 'hello', channel: 'team' } });
expect(new Set(saved.map((entry) => entry.messageId)).size).toBe(2);
expect(saved.every((entry) => typeof entry.messageId === 'string')).toBe(true);
advanceTime();
await get();
await collectFleetChat();
expect(matchChatTimeline(1)).toEqual(saved);
}); });
it('keeps real timestamps and historical match context stable across refreshes', async () => { it('keeps real timestamps and historical match context stable across refreshes', async () => {
+13
View File
@@ -3,6 +3,7 @@ import { render } from 'svelte/server';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import MatchPage from '../../src/routes/matches/[id]/+page.svelte'; import MatchPage from '../../src/routes/matches/[id]/+page.svelte';
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte'; import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
import EvidenceMessages from '../../src/lib/ui/components/moderation/EvidenceMessages.svelte';
import type { MatchDetail } from '../../src/lib/match-history.js'; import type { MatchDetail } from '../../src/lib/match-history.js';
vi.mock('$app/stores', () => ({ vi.mock('$app/stores', () => ({
@@ -45,6 +46,18 @@ const match: MatchDetail = {
}; };
describe('historical match chat', () => { describe('historical match chat', () => {
it('identifies team chat in both archived messages and preserved evidence excerpts', () => {
const entry = match.chat[0];
if (entry.type !== 'message') throw new Error('Expected message fixture');
const history = [{ ...entry, message: { ...entry.message, channel: 'team' as const } }];
expect(render(ChatPanel, { props: { history } }).body).toContain('Team chat');
expect(render(EvidenceMessages, { props: { entries: history } }).body).toContain(
'Team chat'
);
expect(render(ChatPanel, { props: { history: match.chat } }).body).not.toContain(
'Team chat'
);
});
it('renders saved messages with their context and no live controls on the match page', () => { it('renders saved messages with their context and no live controls on the match page', () => {
const body = render(MatchPage, { props: { data: { match } } }).body; const body = render(MatchPage, { props: { data: { match } } }).body;
expect(body).toContain('Match history'); expect(body).toContain('Match history');