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

This commit is contained in:
2026-10-17 16:19:33 -04:00
parent 4501eba873
commit 7f2205e9c4
22 changed files with 2815 additions and 167 deletions
@@ -1,6 +1,14 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ModerationCoverage, ModerationGame, ModerationScope } from '$lib/moderation.js';
import {
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatusLabels,
type ModerationCoverage,
type ModerationGame,
type ModerationScope,
type ModerationCase
} from '$lib/moderation.js';
import { moderationGameName } from '$ui/moderation.js';
import { serverTitle } from '$ui/server-labels.js';
import { confirmAction } from '$ui/confirm.js';
@@ -17,12 +25,24 @@
coverage,
admin,
seed = {},
assignees = [],
onsubmitted
}: {
open?: boolean;
mode: 'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve';
mode:
| 'restriction'
| 'action'
| 'case'
| 'note'
| 'revoke'
| 'case-note'
| 'resolve'
| 'reopen'
| 'edit-case'
| 'evidence';
coverage: ModerationCoverage[];
admin: boolean;
assignees?: { id: string; username: string }[];
seed?: {
game?: ModerationGame;
guid?: string;
@@ -31,8 +51,15 @@
revision?: number;
matchId?: number;
message?: string;
caseId?: string;
caseRevision?: number;
title?: string;
priority?: ModerationCase['priority'];
assigneeId?: string | null;
assignee?: string | null;
status?: ModerationCase['status'];
};
onsubmitted: (target: { game?: ModerationGame; guid?: string | null }) => void;
onsubmitted: (target: { game?: ModerationGame; guid?: string | null; id?: string }) => void;
} = $props();
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
let guid = $state(untrack(() => seed.guid ?? ''));
@@ -43,7 +70,14 @@
let duration = $state('60');
let reason = $state('');
let note = $state('');
let title = $state('');
let title = $state(untrack(() => seed.title ?? ''));
let priority = $state(untrack(() => seed.priority ?? 'normal'));
let assigneeId = $state(untrack(() => seed.assigneeId ?? ''));
let status = $state(untrack(() => seed.status ?? 'open'));
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
let message = $state(untrack(() => seed.message ?? ''));
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
const stateChanged = $derived(mode === 'edit-case' && status !== seed.status);
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action');
let resolution = $state('resolved');
@@ -59,7 +93,10 @@
note: 'Add private note',
revoke: 'Revoke restriction',
'case-note': 'Add case note',
resolve: 'Review case'
resolve: 'Review case',
reopen: 'Reopen case',
'edit-case': 'Edit case',
evidence: 'Attach archived evidence'
}[mode]
);
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
@@ -101,7 +138,8 @@
server: scope === 'server' ? server : undefined,
reason: reason.trim(),
note: note.trim(),
durationMinutes: duration === 'permanent' ? null : Number(duration)
durationMinutes: duration === 'permanent' ? null : Number(duration),
...(seed.caseId ? { caseId: seed.caseId, caseRevision: seed.caseRevision } : {})
};
if (mode === 'action')
return {
@@ -124,7 +162,35 @@
: {})
};
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
if (mode === 'case-note') return { op: 'case-note', id: seed.id, text: note.trim() };
if (mode === 'case-note')
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
if (mode === 'reopen')
return {
op: 'reopen-case',
id: seed.id,
revision: seed.revision,
reason: reason.trim()
};
if (mode === 'edit-case')
return {
op: 'update-case',
id: seed.id,
revision: seed.revision,
title: title.trim(),
priority,
...(assigneeId !== (seed.assigneeId ?? '')
? { assigneeId: assigneeId || null }
: {}),
...(isModerationCaseOpen(status) ? { status } : {}),
reason: reason.trim()
};
if (mode === 'evidence')
return {
op: 'attach-evidence',
id: seed.id,
revision: seed.revision,
evidence: { matchId, message }
};
if (mode === 'revoke')
return {
op: 'revoke-sanction',
@@ -186,7 +252,8 @@
open = false;
onsubmitted({
game: pending.game as ModerationGame | undefined,
guid: pending.guid as string | null | undefined
guid: pending.guid as string | null | undefined,
id: typeof result.id === 'string' ? result.id : undefined
});
} catch (cause) {
if (pending) uncertain = true;
@@ -211,6 +278,7 @@
><Select
bind:value={game}
required
disabled={lockedTarget}
onchange={() => (server = '')}
options={[
{ value: '', label: 'Select a game' },
@@ -229,7 +297,13 @@
label="Player GUID"
required
hint="Use the recorded player identity. Names can change."
><Input name="target-guid" bind:value={guid} required mono /></Field
><Input
name="target-guid"
bind:value={guid}
required
mono
readonly={lockedTarget}
/></Field
>
{/if}
{/if}
@@ -293,11 +367,16 @@
required capability and current policy; review enforcement coverage after
saving.
</p>{/if}
{#if mode === 'case'}
{#if lockedTarget}<p class="hint">
This restriction will be linked to the case and its recorded player identity.
Applying it does not close the case; record the outcome when your review is
complete.
</p>{/if}
{#if mode === 'case' || mode === 'edit-case'}
<Field label="Case title" required
><Input bind:value={title} required maxlength={160} /></Field
>
{#if seed.matchId && seed.message}<div class="evidence">
{#if mode === 'case' && seed.matchId && seed.message}<div class="evidence">
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
<p>{seed.message}</p>
<small
@@ -306,22 +385,102 @@
>
</div>{/if}
{/if}
{#if mode === 'resolve'}<Field label="Decision"
{#if mode === 'edit-case'}
<div class="paired">
<Field label="Priority"
><Select
bind:value={priority}
options={[
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' }
]}
/></Field
>
<Field label="Assigned to"
><Select
bind:value={assigneeId}
options={[
{ value: '', label: 'Unassigned' },
...assignees.map((person) => ({
value: person.id,
label: person.username
})),
...(seed.assigneeId &&
!assignees.some((person) => person.id === seed.assigneeId)
? [
{
value: seed.assigneeId,
label: `${seed.assignee ?? 'Previous assignee'} (unavailable)`
}
]
: [])
]}
/></Field
>
</div>
{#if isModerationCaseOpen(seed.status ?? 'open')}
<Field label="Workflow status"
><Select
bind:value={status}
options={[
{ value: 'open', label: moderationCaseStatusLabels.open },
{ value: 'in_review', label: moderationCaseStatusLabels.in_review },
{ value: 'escalated', label: moderationCaseStatusLabels.escalated }
]}
/></Field
>
{:else}<p class="hint">
The recorded outcome is preserved. Reopen the case to resume review.
</p>{/if}
<Field
label="Change reason"
required={stateChanged}
hint="Required when changing workflow status."
>
<Textarea bind:value={reason} required={stateChanged} rows={3} />
</Field>
{/if}
{#if mode === 'evidence'}
<p class="hint">
Choose a match and copy the exact archived message from this player. The archive
must verify the case's game and player identity before evidence is attached.
</p>
<Field label="Match ID" required
><Input type="number" bind:value={matchId} min={1} step={1} required /></Field
>
<Field label="Exact archived message" required
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
>
{/if}
{#if mode === 'resolve'}<Field label="Outcome"
><Select
bind:value={resolution}
options={[
{ value: 'resolved', label: 'Resolved' },
{ value: 'dismissed', label: 'Dismissed' }
]}
options={moderationCaseOutcomes.map((value) => ({
value,
label: moderationCaseStatusLabels[value]
}))}
/></Field
>{/if}
{#if ['restriction', 'action', 'revoke', 'resolve'].includes(mode)}
{#if mode === 'resolve'}<p class="hint">
This records the decision and closes the case. Apply or revoke restrictions
separately, and send warnings through Live action. Choosing an outcome does not
change enforcement or send a warning.
</p>{/if}
{#if mode === 'reopen'}<p class="hint">
Reopening returns the case to Open and preserves its evidence, restrictions, and
earlier decisions.
</p>{/if}
{#if ['restriction', 'action', 'revoke', 'resolve', 'reopen'].includes(mode)}
<Field
label={mode === 'action' && ['message', 'announce'].includes(action)
? 'Message'
: mode === 'restriction' || mode === 'action'
? 'Player-visible reason'
: 'Review reason'}
: mode === 'reopen'
? 'Reopening reason'
: 'Review reason'}
hint={gameText
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
: undefined}
+64 -1
View File
@@ -1,6 +1,14 @@
import type { ModerationGame } from '$lib/moderation.js';
import {
moderationCaseStatusLabels,
type ModerationCaseStatus,
type ModerationGame
} from '$lib/moderation.js';
import { serverTitle } from './server-labels.js';
export function moderationCaseHref(id: string): string {
return `/moderation/cases/${encodeURIComponent(id)}`;
}
export function moderationHref(context: {
game?: string;
guid?: string;
@@ -35,6 +43,9 @@ export const moderationActionLabels: Record<string, string> = {
'create-case': 'Case opened',
'case-note': 'Case note added',
'resolve-case': 'Case reviewed',
'reopen-case': 'Case reopened',
'update-case': 'Case updated',
'attach-evidence': 'Case evidence attached',
note: 'Private note',
'restriction.created': 'Restriction created',
'restriction.revoked': 'Restriction revoked',
@@ -47,11 +58,63 @@ export const moderationActionLabels: Record<string, string> = {
'case.note': 'Case note added',
'case.resolved': 'Case resolved',
'case.dismissed': 'Case dismissed',
'case.in_review': 'Case under review',
'case.escalated': 'Case escalated',
'case.warning_issued': 'Warning issued',
'case.restriction_applied': 'Restriction applied',
'case.no_action': 'Closed without action',
'case.duplicate': 'Closed as duplicate',
'case.appeal_upheld': 'Appeal upheld',
'case.appeal_denied': 'Appeal denied',
'case.reopened': 'Case reopened',
'case.updated': 'Case updated',
'case.evidence_attached': 'Evidence attached',
'case.restriction': 'Restriction linked to case',
'player.note': 'Private player note',
'runtime.configure': 'Integration configuration requested'
};
export function moderationEventDetail(detail: string, action: string): string {
if (action.startsWith('case.')) {
try {
const record: unknown = JSON.parse(detail);
if (!record || typeof record !== 'object') return detail;
const value = record as Record<string, unknown>;
const display = (item: unknown, status = false): string =>
item === null
? 'Unassigned'
: typeof item === 'string'
? status && Object.hasOwn(moderationCaseStatusLabels, item)
? moderationCaseStatusLabels[item as ModerationCaseStatus]
: item
: '';
if (action === 'case.updated') {
const changes = ['title', 'priority', 'assignee', 'status'].flatMap((key) => {
const change = value[key];
if (!change || typeof change !== 'object') return [];
const { from, to } = change as { from?: unknown; to?: unknown };
return [
`${key === 'assignee' ? 'Assigned to' : key.charAt(0).toUpperCase() + key.slice(1)}: ${display(from, key === 'status')} → ${display(to, key === 'status')}`
];
});
if (typeof value.reason === 'string') changes.push(value.reason);
return changes.join('\n') || detail;
}
if (action === 'case.reopened' && typeof value.reason === 'string')
return `Previous outcome: ${display(value.from, true)}\n${value.reason}`;
if (
action === 'case.evidence_attached' &&
typeof value.count === 'number' &&
typeof value.matchId === 'number'
)
return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`;
if (action === 'case.restriction' && typeof value.reason === 'string')
return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`;
} catch {
// Older case events and decision reasons are plain staff-authored text.
}
return detail;
}
if (!['restriction.created', 'action.queued'].includes(action)) return detail;
try {
const value: unknown = JSON.parse(detail);
+8
View File
@@ -23,8 +23,16 @@ const states: Record<string, [string, StatusTone]> = {
'moderation-active': ['Active', 'warning'],
'moderation-revoked': ['Revoked', 'neutral'],
'moderation-open': ['Open', 'info'],
'moderation-in_review': ['In review', 'info'],
'moderation-escalated': ['Escalated', 'warning'],
'moderation-resolved': ['Resolved', 'online'],
'moderation-dismissed': ['Dismissed', 'neutral'],
'moderation-warning_issued': ['Warning issued', 'online'],
'moderation-restriction_applied': ['Restriction applied', 'online'],
'moderation-no_action': ['No action needed', 'neutral'],
'moderation-duplicate': ['Duplicate', 'neutral'],
'moderation-appeal_upheld': ['Appeal upheld', 'online'],
'moderation-appeal_denied': ['Appeal denied', 'neutral'],
'moderation-queued': ['Queued', 'neutral'],
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
'moderation-confirmed': ['Confirmed', 'online'],