Add structured moderation assessments with revision history
This commit is contained in:
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user