Add structured moderation assessments with revision history

This commit is contained in:
2026-10-17 19:47:28 -04:00
parent aca728b557
commit 428aefdf24
18 changed files with 1182 additions and 2 deletions
+8
View File
@@ -139,6 +139,14 @@ 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
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.
Apply a ban or mute directly from an active case. The case fixes the target game
and GUID and records the resulting restriction alongside the evidence. The same
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
+46
View File
@@ -41,6 +41,49 @@ export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
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 {
game: ModerationGame;
guid: string;
@@ -112,6 +155,7 @@ export interface ModerationCase {
createdAt: string;
updatedAt: string;
revision: number;
assessment?: ModerationCaseAssessment | null;
evidence: {
id: string;
text: string;
@@ -127,6 +171,8 @@ export interface ModerationCaseDetail {
sanctions: ModerationSanction[];
linkedActions?: ModerationCaseAction[];
availableActions?: ModerationCaseAction[];
assessment?: ModerationCaseAssessment | null;
assessmentHistory?: ModerationCaseAssessment[];
events: ModerationEvent[];
coverage: ModerationCoverage[];
assignees: { id: string; username: string }[];
+7 -1
View File
@@ -31,7 +31,8 @@ import { migrateTemperatures } from '../hosts/temperatures.js';
import {
migrateModeration,
migrateModerationCases,
migrateModerationCaseActions
migrateModerationCaseActions,
migrateModerationCaseAssessments
} from '../moderation/schema.js';
import type { Migration } from './migration-runner.js';
@@ -179,6 +180,11 @@ export const migrations: readonly Migration[] = [
version: 31,
name: 'moderation case links to existing actions',
run: migrateModerationCaseActions
},
{
version: 32,
name: 'moderation case assessment revision history',
run: migrateModerationCaseAssessments
}
];
+26
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
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 { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import {
@@ -472,6 +473,31 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
if (input.op === 'action') return liveAction(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 === '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);
+63
View File
@@ -0,0 +1,63 @@
import {
moderationConcernCategories,
moderationFindingOutcomes,
type ModerationCaseAssessment
} from '../../moderation.js';
import { AuthError } from '../auth/index.js';
import { db } from '../db/index.js';
import { textField } from './core.js';
type AssessmentDraft = Pick<ModerationCaseAssessment, 'summary' | 'rules' | 'findings'>;
export function assessmentDraft(input: Record<string, unknown>): AssessmentDraft {
const summary = textField(input.summary, 'Assessment summary', 2000);
if (!Array.isArray(input.rules) || input.rules.length > 20)
throw new AuthError('Choose up to 20 applicable rules.');
const rules = input.rules.map((rule) => textField(rule, 'Applicable rule', 160));
if (
!Array.isArray(input.findings) ||
!input.findings.length ||
input.findings.length > moderationConcernCategories.length
)
throw new AuthError('Record between 1 and 7 findings, one per concern category.');
const categories = new Set<string>();
const findings = input.findings.map((value): AssessmentDraft['findings'][number] => {
if (!value || typeof value !== 'object')
throw new AuthError('Choose a concern category and finding.');
const category = moderationConcernCategories.find(
(category) => category === value.category
);
const finding = moderationFindingOutcomes.find((finding) => finding === value.finding);
if (!category || !finding)
throw new AuthError('Choose a known concern category and finding.');
if (categories.has(category))
throw new AuthError('Record each concern category only once.');
categories.add(category);
return { category, finding, detail: textField(value.detail, 'Finding detail', 1000) };
});
return { summary, rules, findings };
}
export function caseAssessmentHistory(caseId: string): ModerationCaseAssessment[] {
return (
db()
.prepare(
`SELECT revision,data,actor,created_at AS createdAt
FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC`
)
.all(caseId) as { revision: number; data: string; actor: string; createdAt: string }[]
).map(({ data, ...revision }) => ({ ...JSON.parse(data), ...revision }));
}
export function currentCaseAssessment(caseId: string): ModerationCaseAssessment | null {
const row = db()
.prepare(
`SELECT revision,data,actor,created_at AS createdAt
FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1`
)
.get(caseId) as
{ revision: number; data: string; actor: string; createdAt: string } | undefined;
if (!row) return null;
const { data, ...revision } = row;
return { ...JSON.parse(data), ...revision };
}
+4
View File
@@ -3,6 +3,7 @@ import { AuthError } from '../auth/index.js';
import { moderationActor, textField } from './core.js';
import { moderationOverview } from './overview.js';
import { caseActionLists } from './case-actions.js';
import { caseAssessmentHistory } from './assessments.js';
import type {
ModerationCase,
ModerationCaseDetail,
@@ -68,6 +69,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
AND disabled=0 AND must_change_password=0 ORDER BY username COLLATE NOCASE,id`
)
.all() as ModerationCaseDetail['assignees'];
const assessmentHistory = caseAssessmentHistory(caseId);
return {
case: { ...row, evidence },
player: overview.players[0] ?? {
@@ -82,6 +84,8 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
},
sanctions,
...caseActionLists(caseId, row, overview.checkedAt),
assessment: assessmentHistory[0] ?? null,
assessmentHistory,
events,
coverage: overview.coverage,
assignees,
+2
View File
@@ -1,4 +1,5 @@
import { db } from '../db/index.js';
import { currentCaseAssessment } from './assessments.js';
import { moderationActor, moderationNow, policyRevision } from './core.js';
import { runtimeFreshMs } from './actions.js';
import { refreshActionStates } from './runtime.js';
@@ -200,6 +201,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
"CASE WHEN p.status IN ('open','in_review','escalated') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id"
).map((c) => ({
...c,
assessment: currentCaseAssessment(c.id),
evidence: handle
.prepare(
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
+14
View File
@@ -165,3 +165,17 @@ export function migrateModerationCaseActions(handle: Database.Database): void {
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)
);
`);
}
@@ -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>
+4
View File
@@ -68,6 +68,8 @@ export const moderationActionLabels: Record<string, string> = {
'case.appeal_denied': 'Appeal denied',
'case.reopened': 'Case reopened',
'case.updated': 'Case updated',
'case.assessed': 'Assessment revised',
'assess-case': 'Assessment revised',
'case.evidence_attached': 'Evidence attached',
'case.restriction': 'Restriction linked to case',
'case.action_linked': 'Existing action linked to case',
@@ -96,6 +98,8 @@ export function moderationEventDetail(detail: string, action: string): string {
? moderationCaseStatusLabels[item as ModerationCaseStatus]
: item
: '';
if (action === 'case.assessed' && typeof value.summary === 'string')
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
if (action === 'case.updated') {
const changes = ['title', 'priority', 'assignee', 'status'].flatMap((key) => {
const change = value[key];
+1 -1
View File
@@ -19,7 +19,7 @@ export const GET: RequestHandler = ({ locals, url }) => {
export const POST: RequestHandler = async ({ locals, request }) => {
try {
const actor = requireUser(locals.user);
const body = await authBody(request, 16 * 1024);
const body = await authBody(request, 64 * 1024);
if (body.op === 'configure')
return json(
moderationMutation(actor.id, body, () =>
+26
View File
@@ -7,6 +7,8 @@
canRevokeModerationRestriction,
moderationCaseStatuses,
moderationCaseStatusLabels,
moderationConcernLabels,
moderationFindingLabels,
type ModerationOverview,
type ModerationQuery,
type ModerationGame
@@ -29,6 +31,7 @@
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.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 RelativeTime from '$ui/components/RelativeTime.svelte';
import Freshness from '$ui/components/Freshness.svelte';
@@ -609,6 +612,22 @@
>
· {moderationGameName(item.game)}
</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}
<dl>
<div>
<dt>Priority</dt>
@@ -874,4 +893,11 @@
flex: 1 1 100%;
}
}
.assessment-summary {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
</style>
@@ -31,6 +31,7 @@
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 { preservedConversation } from '$ui/moderation-evidence.js';
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
@@ -159,6 +160,11 @@
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) {
if (!raw) return null;
@@ -273,6 +279,14 @@
<div class="workspace">
<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">
<div class="section-heading">
<h2 id="evidence-title">
+214
View File
@@ -0,0 +1,214 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import CaseAssessment from '../../src/lib/ui/components/moderation/CaseAssessment.svelte';
import type { ModerationCaseAssessment } from '../../src/lib/moderation.js';
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const current: ModerationCaseAssessment = {
revision: 4,
summary: 'Slur documented; threatening intent unclear.',
rules: ['Rule 2: Respect other players'],
findings: [
{
category: 'hate_speech',
finding: 'substantiated',
detail: 'The preserved message contains a slur.'
},
{
category: 'threats',
finding: 'inconclusive',
detail: 'The reply could refer to gameplay.'
}
],
actor: 'Moderator One',
createdAt: '2026-10-17T10:00:00Z'
};
function show(
assessment: ModerationCaseAssessment | null = null,
permitted = true,
history: ModerationCaseAssessment[] = []
) {
const props = writable({ revision: 4, assessment });
const state = fromStore(props);
const saved = vi.fn();
component = mount(CaseAssessment, {
target: document.body,
props: {
caseId: 'case-1',
get revision() {
return state.current.revision;
},
get assessment() {
return state.current.assessment;
},
history,
permitted,
onsubmitted: saved
}
});
flushSync();
return {
saved,
update(revision: number, next = assessment) {
props.set({ revision, assessment: next });
flushSync();
}
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
function field<T extends HTMLTextAreaElement | HTMLSelectElement>(label: string): T {
const node = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
}
function enter(label: string, value: string) {
const node = field<HTMLTextAreaElement>(label);
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
function select(label: string, value: string) {
const node = field<HTMLSelectElement>(label);
const query = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: query(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
function complete() {
enter('Current summary', 'Preserved evidence supports a safeguarding review.');
enter('Applicable rules', ' Rule 4: Safeguarding\n\nRule 2: Respect ');
select('Concern 1', 'safeguarding');
select('Finding 1', 'inconclusive');
enter('Explanation 1', 'The surrounding messages establish age but intent remains unclear.');
}
it('creates structured findings and separate rule references with one concern per category', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const { saved } = show();
expect(document.body.textContent).toContain('Not assessed');
button('Assess case').click();
await tick();
complete();
button('Add finding').click();
flushSync();
expect(
[...field<HTMLSelectElement>('Concern 2').options].find(
(option) => option.value === 'safeguarding'
)?.disabled
).toBe(true);
select('Concern 2', 'threats');
select('Finding 2', 'unsubstantiated');
enter('Explanation 2', 'No threat is present in the preserved exchange.');
button('Save assessment').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'assess-case',
id: 'case-1',
revision: 4,
summary: 'Preserved evidence supports a safeguarding review.',
rules: ['Rule 4: Safeguarding', 'Rule 2: Respect'],
findings: [
{
category: 'safeguarding',
finding: 'inconclusive',
detail: 'The surrounding messages establish age but intent remains unclear.'
},
{
category: 'threats',
finding: 'unsubstantiated',
detail: 'No threat is present in the preserved exchange.'
}
],
idempotencyKey: expect.any(String)
});
});
it('shows current findings and preserves prior assessments for read-only viewers', () => {
const previous = {
...current,
revision: 2,
summary: 'Initial concern awaiting full context.',
actor: 'Moderator Two'
};
show(current, false, [current, previous]);
expect(document.body.textContent).toContain(current.summary);
expect(document.body.textContent).toContain('Substantiated');
expect(document.body.textContent).toContain('Inconclusive');
expect(document.querySelector('details summary')?.textContent).toBe('Previous assessments (1)');
expect(document.querySelector('details')?.textContent).toContain(previous.summary);
expect(document.querySelector('details')?.textContent).toContain('Moderator Two');
expect(button('Edit assessment')).toBeUndefined();
expect(document.querySelector('dialog')).toBeNull();
});
it('retains a conflicted assessment and uses the refreshed revision after reopening', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
response({ error: 'This case changed. Reload it before making another change.' }, 409)
)
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const { update, saved } = show(current);
button('Edit assessment').click();
await tick();
expect(field<HTMLTextAreaElement>('Current summary').value).toBe(current.summary);
enter('Current summary', 'Revised conclusion after review.');
button('Save assessment').click();
await vi.waitFor(() =>
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'This case changed.'
)
);
expect(field<HTMLTextAreaElement>('Current summary').value).toBe(
'Revised conclusion after review.'
);
expect(saved).not.toHaveBeenCalled();
button('Cancel').click();
await tick();
update(9);
button('Edit assessment').click();
await tick();
enter('Current summary', 'Reviewed against the refreshed case.');
document.querySelector<HTMLButtonElement>('[aria-label="Remove finding 2"]')!.click();
flushSync();
button('Save assessment').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
revision: 9,
findings: [current.findings[0]]
});
});
it('retries an uncertain assessment as the exact same request', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const { saved } = show();
button('Assess case').click();
await tick();
complete();
button('Save assessment').click();
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
button('Retry same request').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
});
+20
View File
@@ -331,6 +331,20 @@ it('links case records to workspaces and preserves case filters while paging', (
game: 't5',
guid,
title: 'Chat investigation',
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',
priority: 'high',
assigneeId: 'staff-1',
@@ -349,6 +363,12 @@ it('links case records to workspaces and preserves case filters while paging', (
);
expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated');
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();
const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
expect(destination.searchParams.get('caseStatus')).toBe('active');
+273
View File
@@ -0,0 +1,273 @@
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { moderationConcernCategories } from '../../src/lib/moderation.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'moderator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
const create = () =>
mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct' });
const draft = {
summary: 'The documented slur is substantiated; the threatening intent remains unclear.',
rules: ['Rule 1: No discriminatory abuse', 'Rule 2: No threats'],
findings: [
{
category: 'hate_speech',
finding: 'substantiated',
detail: 'The preserved message contains a slur.'
},
{
category: 'threats',
finding: 'inconclusive',
detail: 'The context does not establish a threat.'
}
]
};
const assess = (id: string, revision: number, overrides: Record<string, unknown> = {}) => ({
op: 'assess-case',
id,
revision,
...draft,
...overrides
});
it('preserves current and historical assessments with case revisions, including closed cases', async () => {
const { id } = await create();
expect(await detail(id)).toMatchObject({ assessment: null, assessmentHistory: [] });
const request = { ...assess(id, 1), idempotencyKey: randomUUID() };
await mutate(request);
await mutate(request);
const first = (await detail(id)).assessment!;
expect(first).toEqual({ ...draft, revision: 2, actor: 'moderator', createdAt: clock });
await mutate({
op: 'resolve-case',
id,
revision: 2,
status: 'no_action',
reason: 'Initial review complete'
});
vi.setSystemTime(new Date(Date.parse(clock) + 60_000));
const updated = {
summary: 'Further context does not support a threat.',
rules: [],
findings: [
{
category: 'threats',
finding: 'unsubstantiated',
detail: 'The reply was about the game.'
}
]
};
await mutate(assess(id, 3, updated), 'admin');
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ revision: 4, status: 'no_action' });
expect(data.assessment).toEqual({
...updated,
revision: 4,
actor: 'admin',
createdAt: '2026-10-17T12:01:00.000Z'
});
expect(data.assessmentHistory).toEqual([data.assessment, first]);
expect(JSON.parse(data.events[0].detail)).toEqual({
assessmentRevision: 4,
summary: updated.summary
});
const { currentCaseAssessment } =
await import('../../src/lib/server/moderation/assessments.js');
expect(currentCaseAssessment(id)).toEqual(data.assessment);
expect(currentCaseAssessment('missing')).toBeNull();
expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual({ revision: 1 });
});
it('checks staff access, case revision and normalized no-change submissions before writing history', async () => {
const { id } = await create();
for (const actor of ['viewer', 'super_viewer'])
await expect(mutate(assess(id, 1), actor)).rejects.toMatchObject({ status: 403 });
await mutate(assess(id, 1));
const before = await detail(id);
await expect(mutate(assess(id, 1, { summary: 'Stale edit' }))).rejects.toMatchObject({
status: 409
});
await expect(mutate(assess(id, 2, { summary: ` ${draft.summary} ` }))).rejects.toThrow(
'Change the assessment'
);
expect(await detail(id)).toEqual(before);
await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run();
await expect(mutate(assess(id, 2, { summary: 'Disabled account edit' }))).rejects.toMatchObject(
{ status: 403 }
);
});
it('rejects invalid summaries, rules and findings without adding assessment or audit rows', async () => {
const { id } = await create();
const invalid = [
{ summary: '' },
{ summary: 'a'.repeat(2001) },
{ summary: 'Invalid\u0000summary' },
{ rules: null },
{ rules: [''] },
{ rules: ['a'.repeat(161)] },
{ rules: Array(21).fill('Rule') },
{ findings: [] },
{ findings: null },
{ findings: [null] },
{ findings: [{ category: 'unknown', finding: 'substantiated', detail: 'Evidence' }] },
{ findings: [{ category: 'spam', finding: 'unknown', detail: 'Evidence' }] },
{ findings: [{ category: 'spam', finding: 'substantiated', detail: '' }] },
{ findings: [{ category: 'spam', finding: 'substantiated', detail: 'a'.repeat(1001) }] },
{ findings: [draft.findings[0], draft.findings[0]] },
{ findings: Array(8).fill(draft.findings[0]) }
];
const before = await detail(id);
for (const input of invalid)
await expect(mutate(assess(id, 1, input))).rejects.toMatchObject({ status: 400 });
expect(await detail(id)).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]);
});
it('rolls back history, revisions and request receipts when assessment auditing fails', async () => {
const { id } = await create();
const before = await detail(id);
handle.exec(`CREATE TRIGGER fail_assessment_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.assessed' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const request = { ...assess(id, 1), idempotencyKey: randomUUID() };
await expect(mutate(request)).rejects.toThrow('audit unavailable');
expect(await detail(id)).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
handle.exec('DROP TRIGGER fail_assessment_audit');
await mutate(request);
expect((await detail(id)).assessmentHistory).toHaveLength(1);
});
it('keeps assessment revisions after source match, server and author cleanup', async () => {
handle
.prepare(
"INSERT INTO servers(name,game,map) VALUES('t5_zombie_theater_1','t5','zombie_theater')"
)
.run();
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({
server: 't5_zombie_theater_1',
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Player' }],
receivedAt: clock
});
const { id } = await create();
await mutate(assess(id, 1));
const before = (await detail(id)).assessmentHistory;
handle.prepare('DELETE FROM leaderboard_matches').run();
handle.prepare('DELETE FROM servers').run();
handle.prepare("DELETE FROM auth_users WHERE id='moderator'").run();
expect((await detail(id, 'admin')).assessmentHistory).toEqual(before);
});
it('accepts a valid detailed multibyte assessment over 16 KiB while retaining the 64 KiB request boundary', async () => {
const { id } = await create();
const { POST } = await import('../../src/routes/api/moderation/+server.js');
const { getUser } = await import('../../src/lib/server/auth/index.js');
const input = {
...assess(id, 1, {
summary: '界'.repeat(2000),
rules: Array.from({ length: 20 }, (_, index) => `${index} ${'界'.repeat(155)}`),
findings: moderationConcernCategories.map((category) => ({
category,
finding: 'inconclusive',
detail: '界'.repeat(1000)
}))
}),
idempotencyKey: randomUUID()
};
const body = JSON.stringify(input);
expect(Buffer.byteLength(body)).toBeGreaterThan(16 * 1024);
const post = (body: string) =>
POST({
locals: { user: getUser('moderator') },
request: new Request('http://localhost/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
})
} as Parameters<typeof POST>[0]);
const response = await post(body);
expect(response.status).toBe(200);
expect((await detail(id)).assessment?.findings).toHaveLength(7);
const oversized = await post(
JSON.stringify({ ...input, revision: 2, padding: 'a'.repeat(64 * 1024) })
);
expect(oversized.status).toBe(413);
expect((await detail(id)).assessmentHistory).toHaveLength(1);
});
it('adds assessment storage to an existing database without altering case records', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 31));
legacy
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
VALUES('case','t5','123','Existing case','no_action','former','Former staff',?,?,41)`
)
.run(clock, clock);
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toEqual(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();
}
});
@@ -67,3 +67,36 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
).total
).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);
});