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
+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)
);
`);
}