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
+109 -53
View File
@@ -5,6 +5,8 @@
import { canModerate } from '$lib/auth.js';
import {
canRevokeModerationRestriction,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationOverview,
type ModerationQuery,
type ModerationGame
@@ -14,7 +16,8 @@
import {
moderationActionLabels,
moderationGameName,
moderationEventDetail
moderationEventDetail,
moderationCaseHref
} from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js';
@@ -52,9 +55,7 @@
let generation = 0;
let dialogOpen = $state(false);
let dialogKey = $state(0);
let mode = $state<
'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve'
>('restriction');
let mode = $state<'restriction' | 'action' | 'case' | 'note' | 'revoke'>('restriction');
let seed = $state<{
game?: ModerationGame;
guid?: string;
@@ -160,7 +161,15 @@
dialogKey++;
dialogOpen = true;
}
function submitted(target: { game?: ModerationGame; guid?: string | null }) {
function submitted(target: { game?: ModerationGame; guid?: string | null; id?: string }) {
if (mode === 'case' && target.id) {
cancelRefresh();
void goto(moderationCaseHref(target.id), { invalidateAll: true }).catch(() => {
navigationError = 'Case saved. Open it from the case list or refresh to try again.';
void refresh();
});
return;
}
notice = 'Saved. Enforcement and delivery status are shown below.';
cancelRefresh();
const view =
@@ -320,7 +329,15 @@
class="filters"
onsubmit={(event) => {
event.preventDefault();
void navigate({ q: filters.q, game: filters.game, guid: filters.guid, offset: null });
void navigate({
q: filters.q,
game: filters.game,
guid: filters.guid,
caseStatus: filters.caseStatus ?? null,
priority: filters.priority ?? null,
assignedTo: filters.assignedTo ?? null,
offset: null
});
}}
>
<Field label="Search moderation" class="search"
@@ -343,6 +360,47 @@
/></Field
>
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
{#if report.query.view === 'cases'}
<Field label="Case status"
><Select
name="caseStatus"
bind:value={filters.caseStatus}
options={[
{ value: '', label: 'All statuses' },
{ value: 'active', label: 'Active cases' },
{ value: 'closed', label: 'Closed cases' },
...moderationCaseStatuses.map((status) => ({
value: status,
label: moderationCaseStatusLabels[status]
}))
]}
/></Field
>
<Field label="Priority"
><Select
name="priority"
bind:value={filters.priority}
options={[
{ value: '', label: 'All priorities' },
{ value: 'urgent', label: 'Urgent' },
{ value: 'high', label: 'High' },
{ value: 'normal', label: 'Normal' },
{ value: 'low', label: 'Low' }
]}
/></Field
>
<Field label="Assignment"
><Select
name="assignedTo"
bind:value={filters.assignedTo}
options={[
{ value: '', label: 'Anyone' },
{ value: 'me', label: 'Assigned to me' },
{ value: 'unassigned', label: 'Unassigned' }
]}
/></Field
>
{/if}
<Button type="submit" disabled={busy}>Apply</Button>
<Button
disabled={busy}
@@ -354,7 +412,10 @@
offset: null,
server: null,
message: null,
matchId: null
matchId: null,
caseStatus: null,
priority: null,
assignedTo: null
})}>Reset</Button
>
</form>
@@ -507,6 +568,9 @@
</dd>
</div>
</dl>
{#if sanction.caseId}<a href={moderationCaseHref(sanction.caseId)}
>View linked case</a
>{/if}
{#if sanction.note}<p class="private-note">
<strong>Private note</strong>
{sanction.note}
@@ -536,47 +600,46 @@
{#if report.cases.length}<div class="record-list">
{#each report.cases as item (item.id)}<article class="record">
<div class="record-heading">
<h2>{item.title}</h2>
<h2><a href={moderationCaseHref(item.id)}>{item.title}</a></h2>
<StatusBadge state={`moderation-${item.status}`} />
</div>
<p>
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
>{item.guid}</a
>
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime
value={item.createdAt}
/>
· {moderationGameName(item.game)}
</p>
<details>
<summary>Evidence and staff notes ({item.evidence.length})</summary>
<ol class="evidence-list">
{#each item.evidence as evidence (evidence.id)}<li>
<p>{evidence.text}</p>
<small
>{evidence.source} · {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/></small
>
</li>{:else}<li>No evidence or notes recorded.</li>{/each}
</ol>
</details>
<dl>
<div>
<dt>Priority</dt>
<dd>
{item.priority.charAt(0).toUpperCase() +
item.priority.slice(1)}
</dd>
</div>
<div>
<dt>Assigned to</dt>
<dd>{item.assignee ?? 'Unassigned'}</dd>
</div>
<div>
<dt>Updated</dt>
<dd><RelativeTime value={item.updatedAt} /></dd>
</div>
<div>
<dt>Opened by</dt>
<dd>{item.actor} · <RelativeTime value={item.createdAt} /></dd>
</div>
</dl>
<div class="record-heading">
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div
class="row-actions"
>
<Button
size="sm"
onclick={() => openDialog('case-note', { id: item.id })}
>Add case note</Button
>{#if item.status === 'open'}<Button
size="sm"
onclick={() =>
openDialog('resolve', {
id: item.id,
revision: item.revision
})}>Review case</Button
>{/if}
</div>{/if}
<span class="hint"
>{item.evidence.length} evidence and staff note {item.evidence
.length === 1
? 'entry'
: 'entries'}</span
>
<Button href={moderationCaseHref(item.id)} size="sm"
>View case</Button
>
</div>
</article>{/each}
</div>{:else}<EmptyState
@@ -628,10 +691,12 @@
: ''}{event.game
? ` · ${moderationGameName(event.game)}`
: ''}</small
>{#if event.subjectId}<CopyValue
value={event.subjectId}
label="Record ID"
/>{/if}
>{#if event.subjectId}
{#if event.action.startsWith('case.')}<a
href={moderationCaseHref(event.subjectId)}>View case</a
>{/if}
<CopyValue value={event.subjectId} label="Record ID" />
{/if}
</li>{/each}
</ol>{:else}<EmptyState
title="No history found"
@@ -788,15 +853,6 @@
.private-note {
font-size: var(--text-sm);
}
.evidence-list {
padding-left: var(--space-5);
}
.evidence-list li {
margin-top: var(--space-4);
}
.evidence-list small {
font-size: var(--text-xs);
}
.audit-list {
list-style: none;
padding: 0;