Add case follow-up queues and personal update notifications

This commit is contained in:
2026-10-17 20:00:13 -04:00
parent 90b94911c5
commit b678de2f6f
19 changed files with 1408 additions and 16 deletions
+7 -1
View File
@@ -33,7 +33,8 @@ import {
migrateModerationCases,
migrateModerationCaseActions,
migrateModerationCaseAssessments,
migrateModerationCaseRelationships
migrateModerationCaseRelationships,
migrateModerationCaseFollowups
} from '../moderation/schema.js';
import type { Migration } from './migration-runner.js';
@@ -191,6 +192,11 @@ export const migrations: readonly Migration[] = [
version: 33,
name: 'moderation case relationships and duplicate review',
run: migrateModerationCaseRelationships
},
{
version: 34,
name: 'moderation follow-up dates and personal updates',
run: migrateModerationCaseFollowups
}
];
+43 -1
View File
@@ -4,6 +4,7 @@ import { archivedEvidenceSnapshots } from './evidence.js';
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
import { assessmentDraft } from './assessments.js';
import { linkCases, unlinkCases } from './relationships.js';
import { notifyCase, caseReviewSchedule, readCaseUpdates } from './followups.js';
import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import {
@@ -53,6 +54,9 @@ type CaseRow = {
title: string;
priority: ModerationCasePriority;
assignee_id: string | null;
review_at: string | null;
review_reason: string;
review_sanction_id: string | null;
};
function serverTarget(name: unknown, game?: ModerationGame) {
@@ -390,7 +394,8 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
assignee.id
);
if (note) evidence(actor, id, note, 'Staff note', null, now);
if (input.evidence !== undefined) archivedEvidence(actor, id, target, input.evidence, now);
const attached =
input.evidence === undefined ? 0 : archivedEvidence(actor, id, target, input.evidence, now);
moderationEvent(actor, 'case.opened', target, id, title, now);
if (priority !== 'normal' || assignee.id)
moderationEvent(
@@ -404,6 +409,8 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
}),
now
);
if (note || attached) notifyCase(actor.id, id, 'evidence', now);
if (assignee.id) notifyCase(actor.id, id, 'assignment', now);
return { id };
}
function archivedEvidence(
@@ -465,6 +472,8 @@ function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string
JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }),
now
);
if (assigneeId !== row.assignee_id)
notifyCase(actor.id, row.id, 'assignment', now, row.assignee_id);
return { id: row.id };
}
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
@@ -474,6 +483,37 @@ 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 === 'schedule-case-review') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const schedule = caseReviewSchedule(row, input, now);
if (
row.review_at === schedule.reviewAt &&
row.review_reason === schedule.reason &&
row.review_sanction_id === schedule.restrictionId
)
return { id: row.id };
db()
.prepare(
`UPDATE moderation_cases SET review_at=?,review_reason=?,review_sanction_id=?,
updated_at=?,revision=revision+1 WHERE id=?`
)
.run(schedule.reviewAt, schedule.reason, schedule.restrictionId, now, row.id);
moderationEvent(
actor,
'case.review_scheduled',
row,
row.id,
JSON.stringify(schedule),
now
);
return { id: row.id };
}
if (input.op === 'read-case-updates') {
const row = caseRow(input.id);
readCaseUpdates(actor.id, row.id, input.throughId, now);
return { id: row.id };
}
if (input.op === 'link-case' || input.op === 'unlink-case') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
@@ -542,6 +582,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
evidence(actor, row.id, text, 'Staff note', null, now);
touchCase(row.id, now);
moderationEvent(actor, 'case.note', row, row.id, text, now);
notifyCase(actor.id, row.id, 'evidence', now);
return { id: row.id };
}
if (input.op === 'resolve-case') {
@@ -626,6 +667,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
now
);
notifyCase(actor.id, row.id, 'evidence', now);
return { id: row.id };
}
if (input.op === 'note') {
+5 -2
View File
@@ -5,6 +5,7 @@ import { moderationOverview } from './overview.js';
import { caseActionLists } from './case-actions.js';
import { caseAssessmentHistory } from './assessments.js';
import { relatedCases, candidateCases } from './relationships.js';
import { caseNotifications, unreadCaseUpdates } from './followups.js';
import type {
ModerationCase,
ModerationCaseDetail,
@@ -22,7 +23,8 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
.prepare(
`SELECT c.id,c.game,c.guid,c.title,c.status,c.priority,
c.assignee_id AS assigneeId,u.username AS assignee,c.actor,c.created_at AS createdAt,
c.updated_at AS updatedAt,c.revision FROM moderation_cases c
c.updated_at AS updatedAt,c.revision,c.review_at AS reviewAt,
c.review_reason AS reviewReason,c.review_sanction_id AS reviewSanctionId FROM moderation_cases c
LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?`
)
.get(caseId) as Omit<ModerationCase, 'evidence'> | undefined;
@@ -72,7 +74,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
.all() as ModerationCaseDetail['assignees'];
const assessmentHistory = caseAssessmentHistory(caseId);
return {
case: { ...row, evidence },
case: { ...row, evidence, unreadUpdates: unreadCaseUpdates(actorId, caseId) },
player: overview.players[0] ?? {
game: row.game,
guid: row.guid,
@@ -89,6 +91,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
assessmentHistory,
relatedCases: relatedCases(caseId),
candidateCases: candidateCases(row),
notifications: caseNotifications(actorId, caseId),
events,
coverage: overview.coverage,
assignees,
+94
View File
@@ -0,0 +1,94 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import type { ModerationCaseNotification, ModerationGame } from '../../moderation.js';
import { textField } from './core.js';
import { recordedCaseAction, isCaseActionLinked } from './case-actions.js';
/** Called once per accepted mutation, within its existing audit transaction. */
export function notifyCase(
actorId: string,
caseId: string,
kind: ModerationCaseNotification['kind'],
now: string,
previousAssignee: string | null = null
) {
db()
.prepare(
`INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at)
SELECT u.id,?,?,? FROM auth_users u JOIN moderation_cases c ON c.id=?
WHERE (u.id=c.actor_id OR u.id=c.assignee_id OR u.id=?) AND u.id<>?
AND u.role IN ('admin','moderator') AND u.disabled=0 AND u.must_change_password=0`
)
.run(caseId, kind, now, caseId, previousAssignee, actorId);
}
export function caseNotifications(actorId: string, caseId?: string): ModerationCaseNotification[] {
return db()
.prepare(
`SELECT n.id,n.case_id AS caseId,c.title AS caseTitle,n.kind,
n.created_at AS createdAt,n.read_at AS readAt FROM moderation_case_notifications n
JOIN moderation_cases c ON c.id=n.case_id WHERE n.user_id=?
${caseId === undefined ? 'AND n.read_at IS NULL' : 'AND n.case_id=?'} ORDER BY n.id DESC LIMIT 25`
)
.all(
...(caseId === undefined ? [actorId] : [actorId, caseId])
) as ModerationCaseNotification[];
}
export function unreadCaseUpdates(actorId: string, caseId?: string): number {
return (
db()
.prepare(
`SELECT COUNT(*) AS count FROM moderation_case_notifications
WHERE user_id=? AND read_at IS NULL${caseId === undefined ? '' : ' AND case_id=?'}`
)
.get(...(caseId === undefined ? [actorId] : [actorId, caseId])) as { count: number }
).count;
}
export function readCaseUpdates(actorId: string, caseId: string, throughId: unknown, now: string) {
if (!Number.isSafeInteger(throughId) || (throughId as number) < 0)
throw new AuthError('Choose the last update you have reviewed.');
db()
.prepare(
`UPDATE moderation_case_notifications SET read_at=?
WHERE user_id=? AND case_id=? AND id<=? AND read_at IS NULL`
)
.run(now, actorId, caseId, throughId);
}
function canonicalDate(value: unknown): string {
if (
typeof value !== 'string' ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value
)
throw new AuthError('Choose a valid review date and time.');
return value;
}
export function caseReviewSchedule(
subject: { id: string; game: ModerationGame; guid: string },
input: Record<string, unknown>,
now: string
) {
const reason = textField(input.reason, 'Review reason', 4000);
if ((input.reviewAt === undefined) === (input.restrictionId === undefined))
throw new AuthError('Choose either a review date or a linked mute.');
if (input.restrictionId !== undefined) {
const id = textField(input.restrictionId, 'Restriction ID', 128);
const reference = { type: 'restriction' as const, id };
const restriction = recordedCaseAction(subject, reference, now);
if (!isCaseActionLinked(subject.id, reference))
throw new AuthError('Link the mute to this case before scheduling its review.', 409);
if (restriction.kind !== 'mute' || !restriction.expiresAt)
throw new AuthError('Choose a linked mute with a recorded expiry time.');
return { reviewAt: canonicalDate(restriction.expiresAt), reason, restrictionId: id };
}
return {
reviewAt: input.reviewAt === null ? null : canonicalDate(input.reviewAt),
reason,
restrictionId: null
};
}
+30 -6
View File
@@ -1,5 +1,6 @@
import { db } from '../db/index.js';
import { currentCaseAssessment } from './assessments.js';
import { caseNotifications, unreadCaseUpdates } from './followups.js';
import { moderationActor, moderationNow, policyRevision } from './core.js';
import { runtimeFreshMs } from './actions.js';
import { refreshActionStates } from './runtime.js';
@@ -23,6 +24,7 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
const offset = Number(params.get('offset') ?? 0);
const caseStatus = params.get('caseStatus') ?? '';
const priority = params.get('priority') ?? '';
const review = params.get('review') ?? '';
return {
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
q: (params.get('q') ?? '').trim().slice(0, 200),
@@ -35,7 +37,10 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
? (priority as ModerationQuery['priority'])
: '',
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128)
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128),
review: ['due', 'scheduled', 'updates'].includes(review)
? (review as ModerationQuery['review'])
: ''
};
}
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
@@ -90,7 +95,9 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
coverage,
total: 0,
limit,
checkedAt: now
checkedAt: now,
notifications: caseNotifications(actorId),
unreadCount: unreadCaseUpdates(actorId)
};
const where: string[] = [],
values: (string | number)[] = [];
@@ -122,6 +129,16 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
where.push('p.assignee_id=?');
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
}
if (query.review === 'due') {
where.push('p.review_at IS NOT NULL AND p.review_at<=?');
values.push(now);
} else if (query.review === 'scheduled') where.push('p.review_at IS NOT NULL');
else if (query.review === 'updates') {
where.push(
'EXISTS (SELECT 1 FROM moderation_case_notifications n WHERE n.case_id=p.id AND n.user_id=? AND n.read_at IS NULL)'
);
values.push(actorId);
}
}
if (query.q) {
const extra =
@@ -138,7 +155,12 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
values.push(query.q, query.q, query.q);
}
const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
function rows<T>(table: string, select: string, order: string): T[] {
function rows<T>(
table: string,
select: string,
order: string,
orderValues: string[] = []
): T[] {
result.total = (
handle
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
@@ -152,7 +174,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
.prepare(
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
)
.all(...values, limit, result.query.offset) as T[];
.all(...values, ...orderValues, limit, result.query.offset) as T[];
}
if (query.view === 'players') {
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
@@ -197,11 +219,13 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
} else if (query.view === 'cases') {
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
'moderation_cases',
'p.id,p.game,p.guid,p.title,p.status,p.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
"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"
'p.id,p.game,p.guid,p.title,p.status,p.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision,p.review_at AS reviewAt,p.review_reason AS reviewReason,p.review_sanction_id AS reviewSanctionId',
"CASE WHEN p.review_at<=? THEN 0 ELSE 1 END,CASE WHEN p.review_at<=? THEN p.review_at END,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",
[now, now]
).map((c) => ({
...c,
assessment: currentCaseAssessment(c.id),
unreadUpdates: unreadCaseUpdates(actorId, 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'
+21
View File
@@ -202,3 +202,24 @@ export function migrateModerationCaseRelationships(handle: Database.Database): v
ON moderation_cases(game,guid,updated_at DESC,id);
`);
}
export function migrateModerationCaseFollowups(handle: Database.Database): void {
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
if (!columns.some((column) => column.name === 'review_at'))
handle.exec(`ALTER TABLE moderation_cases ADD COLUMN review_at TEXT;
ALTER TABLE moderation_cases ADD COLUMN review_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE moderation_cases ADD COLUMN review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL;`);
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_case_review_due ON moderation_cases(review_at) WHERE review_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS moderation_case_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
kind TEXT NOT NULL CHECK(kind IN ('evidence','assignment')),
created_at TEXT NOT NULL,
read_at TEXT
);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_unread ON moderation_case_notifications(user_id,read_at,id DESC);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_case ON moderation_case_notifications(user_id,case_id,read_at,id DESC);
`);
}