Compare commits
7
Commits
83f1c91877
...
48653af3dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48653af3dc | ||
|
|
870f13b150 | ||
|
|
b678de2f6f | ||
|
|
90b94911c5 | ||
|
|
428aefdf24 | ||
|
|
aca728b557 | ||
|
|
ef96a2c308 |
@@ -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
@@ -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 } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +208,15 @@
|
|||||||
<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
|
||||||
? 'At match end'
|
? 'online'
|
||||||
: 'Not at match end'}</Badge
|
: 'neutral'}
|
||||||
|
>{!completed
|
||||||
|
? 'Recorded participant'
|
||||||
|
: player.presentAtEnd
|
||||||
|
? 'At match end'
|
||||||
|
: '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
|
||||||
>
|
>
|
||||||
{#each stats as stat}<td class:round={stat.key === 'highestRound'}
|
{#each stats as stat}<td class:round={stat.key === 'highestRound'}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user