From 428aefdf249bb4d6f12d04249474047bbd28e326 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 17 Oct 2026 19:47:28 -0400 Subject: [PATCH] Add structured moderation assessments with revision history --- docs/guides/moderation.md | 8 + src/lib/moderation.ts | 46 +++ src/lib/server/db/migrations.ts | 8 +- src/lib/server/moderation/actions.ts | 26 ++ src/lib/server/moderation/assessments.ts | 63 ++++ src/lib/server/moderation/cases.ts | 4 + src/lib/server/moderation/overview.ts | 2 + src/lib/server/moderation/schema.ts | 14 + .../moderation/CaseAssessment.svelte | 167 +++++++++++ .../moderation/CaseAssessmentDialog.svelte | 260 +++++++++++++++++ src/lib/ui/moderation.ts | 4 + src/routes/api/moderation/+server.ts | 2 +- src/routes/moderation/+page.svelte | 26 ++ src/routes/moderation/cases/[id]/+page.svelte | 14 + tests/client/moderation-assessment.test.ts | 214 ++++++++++++++ tests/client/moderation-page.test.ts | 20 ++ tests/unit/moderation-assessments.test.ts | 273 ++++++++++++++++++ tests/unit/moderation-case-overview.test.ts | 33 +++ 18 files changed, 1182 insertions(+), 2 deletions(-) create mode 100644 src/lib/server/moderation/assessments.ts create mode 100644 src/lib/ui/components/moderation/CaseAssessment.svelte create mode 100644 src/lib/ui/components/moderation/CaseAssessmentDialog.svelte create mode 100644 tests/client/moderation-assessment.test.ts create mode 100644 tests/unit/moderation-assessments.test.ts diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md index 9191bb0..43cfd92 100644 --- a/docs/guides/moderation.md +++ b/docs/guides/moderation.md @@ -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 diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index da70f49..bd0ba5d 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -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 = { + 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 = { + 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 }[]; diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 2abffa2..a50785c 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -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 } ]; diff --git a/src/lib/server/moderation/actions.ts b/src/lib/server/moderation/actions.ts index 8f25a7d..00888dc 100644 --- a/src/lib/server/moderation/actions.ts +++ b/src/lib/server/moderation/actions.ts @@ -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 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); diff --git a/src/lib/server/moderation/assessments.ts b/src/lib/server/moderation/assessments.ts new file mode 100644 index 0000000..fb719c6 --- /dev/null +++ b/src/lib/server/moderation/assessments.ts @@ -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; + +export function assessmentDraft(input: Record): 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(); + 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 }; +} diff --git a/src/lib/server/moderation/cases.ts b/src/lib/server/moderation/cases.ts index 4841f42..224943d 100644 --- a/src/lib/server/moderation/cases.ts +++ b/src/lib/server/moderation/cases.ts @@ -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, diff --git a/src/lib/server/moderation/overview.ts b/src/lib/server/moderation/overview.ts index bd1e89f..dda025e 100644 --- a/src/lib/server/moderation/overview.ts +++ b/src/lib/server/moderation/overview.ts @@ -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' diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts index d753171..782326a 100644 --- a/src/lib/server/moderation/schema.ts +++ b/src/lib/server/moderation/schema.ts @@ -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) + ); + `); +} diff --git a/src/lib/ui/components/moderation/CaseAssessment.svelte b/src/lib/ui/components/moderation/CaseAssessment.svelte new file mode 100644 index 0000000..6d8a788 --- /dev/null +++ b/src/lib/ui/components/moderation/CaseAssessment.svelte @@ -0,0 +1,167 @@ + + +{#snippet assessmentContent(value: ModerationCaseAssessment)} +

{value.summary}

+
+

Applicable rules

+ {#if value.rules.length}
    + {#each value.rules as rule}
  • {rule}
  • {/each} +
{:else}

No rule references recorded.

{/if} +
+
    + {#each value.findings as finding}
  1. +
    + {moderationConcernLabels[finding.category]}{moderationFindingLabels[finding.finding]} +
    +

    {finding.detail}

    +
  2. {/each} +
+

+ Revision {value.revision} · {value.actor} · +

+{/snippet} + +
+
+

Case assessment

+ {#if permitted}{/if} +
+ {#if assessment}{@render assessmentContent(assessment)}{:else}

+ Not assessed. Record concerns, applicable rules, and what the evidence supports. +

{/if} + {#if previous.length}
+ Previous assessments ({previous.length}) +
    + {#each previous as earlier (earlier.revision)}
  1. + {@render assessmentContent(earlier)} +
  2. {/each} +
+
{/if} +
+ +{#if permitted && open}{/if} + + diff --git a/src/lib/ui/components/moderation/CaseAssessmentDialog.svelte b/src/lib/ui/components/moderation/CaseAssessmentDialog.svelte new file mode 100644 index 0000000..c68ae73 --- /dev/null +++ b/src/lib/ui/components/moderation/CaseAssessmentDialog.svelte @@ -0,0 +1,260 @@ + + + +
+
+