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
+14
View File
@@ -157,6 +157,20 @@ direction made explicit. Self-links and circular appeal/duplicate chains are
rejected. Removing a mistaken relationship requires a reason and preserves its
history. Relationship changes do not close or reopen either case.
Schedule a follow-up using a date and time, or **Review when a linked mute expires**.
Custom date entry uses your browser's local time; displayed dates follow your time
zone preference. Reviews remain scheduled after a case closes or reopens. Use
**Overdue reviews** to find due work across open and closed cases, and clear the
follow-up with a reason when the review is complete.
In-app case updates notify the assignee and case creator when another staff member
adds evidence or a staff note. Assignment changes notify the previous and new assignees
and the creator, excluding the person making the change. **Your unread updates**
is personal to your account. Open a case to review and mark existing updates
read; notifications arriving afterward stay unread. Queue and case pages refresh
every 15 seconds while visible. These are in-app notifications, with no email or
external messaging delivery.
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
+16
View File
@@ -163,6 +163,10 @@ export interface ModerationCase {
updatedAt: string;
revision: number;
assessment?: ModerationCaseAssessment | null;
reviewAt?: string | null;
reviewReason?: string;
reviewSanctionId?: string | null;
unreadUpdates?: number;
evidence: {
id: string;
text: string;
@@ -182,6 +186,7 @@ export interface ModerationCaseDetail {
assessmentHistory?: ModerationCaseAssessment[];
relatedCases?: ModerationCaseRelationship[];
candidateCases?: ModerationCaseCandidate[];
notifications?: ModerationCaseNotification[];
events: ModerationEvent[];
coverage: ModerationCoverage[];
assignees: { id: string; username: string }[];
@@ -202,6 +207,14 @@ export interface ModerationCaseRelationship {
export interface ModerationCaseCandidate extends ModerationCaseReference {
sharedMatch: boolean;
}
export interface ModerationCaseNotification {
id: number;
caseId: string;
caseTitle: string;
kind: 'evidence' | 'assignment';
createdAt: string;
readAt: string | null;
}
export interface ModerationCaseAction {
id: string;
type: 'restriction' | 'action';
@@ -240,6 +253,7 @@ export interface ModerationQuery {
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
priority?: '' | ModerationCasePriority;
assignedTo?: string;
review?: '' | 'due' | 'scheduled' | 'updates';
}
export interface ModerationOverview {
query: ModerationQuery;
@@ -252,6 +266,8 @@ export interface ModerationOverview {
total: number;
limit: number;
checkedAt: string;
notifications?: ModerationCaseNotification[];
unreadCount?: number;
}
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
+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);
`);
}
@@ -0,0 +1,97 @@
<script lang="ts">
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
import Button from '../primitives/Button.svelte';
import CaseReviewDialog from './CaseReviewDialog.svelte';
let {
item,
actions = [],
checkedAt,
permitted,
onsubmitted
}: {
item: ModerationCase;
actions?: ModerationCaseAction[];
checkedAt: string;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let open = $state(false);
let clearing = $state(false);
const overdue = $derived(!!item.reviewAt && Date.parse(item.reviewAt) <= Date.parse(checkedAt));
$effect(() => {
void item.id;
open = false;
});
</script>
<section aria-label="Follow-up review">
<div class="heading">
<h2>Follow-up review</h2>
{#if overdue}<Badge variant="warning">Overdue review</Badge>{/if}
</div>
{#if item.reviewAt}<p>
<time datetime={item.reviewAt}>{$timeFormat.dateTime(item.reviewAt)}</time>
</p>
{#if item.reviewSanctionId}<p class="hint">Review after linked mute expires.</p>{/if}
{:else}<p class="hint">No review scheduled.</p>{/if}
{#if item.reviewReason}<p class="reason">{item.reviewReason}</p>{/if}
{#if permitted}<div class="actions">
<Button
size="sm"
onclick={() => {
clearing = false;
open = true;
}}>{item.reviewAt ? 'Reschedule review' : 'Schedule review'}</Button
>
{#if item.reviewAt}<Button
size="sm"
onclick={() => {
clearing = true;
open = true;
}}>Complete or clear review</Button
>{/if}
</div>{/if}
</section>
{#if permitted && open}<CaseReviewDialog bind:open {item} {actions} {clearing} {onsubmitted} />{/if}
<style>
section {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading,
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.heading {
justify-content: space-between;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
margin: 0;
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.reason {
white-space: pre-wrap;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
</style>
@@ -0,0 +1,180 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
let {
open = $bindable(false),
item,
actions,
clearing = false,
onsubmitted
}: {
open?: boolean;
item: ModerationCase;
actions: ModerationCaseAction[];
clearing?: boolean;
onsubmitted: () => void;
} = $props();
const openedRevision = untrack(() => item.revision);
const caseId = untrack(() => item.id);
const mutes = $derived(
actions.filter(
(action) => action.type === 'restriction' && action.kind === 'mute' && action.expiresAt
)
);
let source = $state(untrack(() => item.reviewSanctionId ?? 'custom'));
function browserDate(iso?: string | null): string {
if (!iso) return '';
const date = new Date(iso);
if (!Number.isFinite(date.getTime())) return '';
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
let date = $state(untrack(() => browserDate(item.reviewAt)));
let reason = $state(untrack(() => (clearing ? '' : (item.reviewReason ?? ''))));
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 chosen = new Date(date);
if (
!reason.trim() ||
(!clearing && source === 'custom' && (!date || !Number.isFinite(chosen.getTime())))
) {
error = 'Choose a review date and record a reason for this follow-up change.';
return;
}
if (!clearing && source !== 'custom' && !mutes.some((mute) => mute.id === source)) {
error = 'Choose an available linked mute or a custom review date.';
return;
}
pending = {
op: 'schedule-case-review',
id: caseId,
revision: openedRevision,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID(),
...(clearing
? { reviewAt: null }
: source === 'custom'
? { reviewAt: chosen.toISOString() }
: { restrictionId: source })
};
}
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 update the follow-up review.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not update the follow-up review.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={clearing ? 'Complete or clear review' : 'Schedule follow-up review'}
{busy}
description={clearing
? 'Record the review result or why the follow-up is no longer needed. This clears its due date.'
: 'Follow-up reviews remain scheduled when a case is closed.'}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
{#if !clearing}
<Field label="Review timing"
><Select
bind:value={source}
options={[
{ value: 'custom', label: 'Choose a date and time' },
...mutes.map((mute) => ({
value: mute.id,
label: `After mute expires · ${$timeFormat.dateTime(mute.expiresAt!)} · ${mute.reason}`
}))
]}
/></Field
>
{#if source === 'custom'}<Field label="Review date (browser local time)" required
><Input type="datetime-local" bind:value={date} required /></Field
>
{:else}<p class="hint">
The review will be due at the linked mute's recorded expiration time.
</p>{/if}
{/if}
<Field
label={clearing ? 'Review result or clearing reason' : 'Follow-up reason'}
required><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
>
</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 request 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'
: clearing
? 'Complete or clear review'
: 'Save follow-up'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
padding: 0;
margin: 0;
border: 0;
}
.hint {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
</style>
@@ -0,0 +1,131 @@
<script lang="ts">
import type { ModerationCaseNotification } from '$lib/moderation.js';
import Button from '../primitives/Button.svelte';
import Badge from '../primitives/Badge.svelte';
import RelativeTime from '../RelativeTime.svelte';
let {
caseId,
notifications = [],
unread = 0,
permitted,
onsubmitted
}: {
caseId: string;
notifications?: ModerationCaseNotification[];
unread?: number;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let busy = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = $state(null);
const throughId = $derived(Math.max(0, ...notifications.map((item) => item.id)));
$effect(() => {
void caseId;
pending = null;
error = '';
});
async function markSeen() {
if (busy || !permitted || (!pending && !throughId)) return;
pending ??= {
op: 'read-case-updates',
id: caseId,
throughId,
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) {
if (response.status < 500 && !response.ok) pending = null;
throw new Error(result?.error || 'Could not mark these updates as seen.');
}
pending = null;
onsubmitted();
} catch (cause) {
error =
cause instanceof Error ? cause.message : 'Could not mark these updates as seen.';
} finally {
busy = false;
}
}
</script>
<section aria-label="Your case updates">
<div class="heading">
<h2>Your updates</h2>
{#if unread}<Badge variant="warning">{unread} unread</Badge>{/if}
</div>
{#if notifications.length}<ol>
{#each notifications as update (update.id)}<li>
<div class="heading">
<strong
>{update.kind === 'evidence'
? 'Evidence or note added'
: 'Assignment changed'}</strong
>{#if !update.readAt}<Badge>Unread</Badge>{/if}
</div>
<RelativeTime value={update.createdAt} />
</li>{/each}
</ol>{:else}<p class="hint">No personal case updates.</p>{/if}
{#if notifications.length >= 25}<p class="hint">
Showing your latest 25 updates for this case.
</p>{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if permitted && (unread || pending)}<p class="hint">
Marks your updates through the newest shown entry as read.
</p>
<Button size="sm" disabled={!throughId} loading={busy} onclick={markSeen}
>{pending && error ? 'Retry same request' : 'Mark updates as seen'}</Button
>{/if}
</section>
<style>
section {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
ol {
list-style: none;
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
}
li {
display: grid;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
.hint {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
</style>
+4
View File
@@ -71,6 +71,8 @@ export const moderationActionLabels: Record<string, string> = {
'case.reopened': 'Case reopened',
'case.updated': 'Case updated',
'case.assessed': 'Assessment revised',
'case.review_scheduled': 'Follow-up updated',
'schedule-case-review': 'Follow-up updated',
'case.linked': 'Case relationship added',
'case.unlinked': 'Case relationship removed',
'link-case': 'Case relationship added',
@@ -119,6 +121,8 @@ export function moderationEventDetail(detail: string, action: string): string {
: moderationCaseRelationLabels[value.relation as ModerationCaseRelation];
return `${label}: ${value.relatedCaseId}\n${value.reason}`;
}
if (action === 'case.review_scheduled' && typeof value.reason === 'string')
return `${typeof value.reviewAt === 'string' ? `Review scheduled for ${value.reviewAt}` : 'Follow-up cleared'}\n${value.reason}`;
if (action === 'case.assessed' && typeof value.summary === 'string')
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
if (action === 'case.updated') {
+70 -1
View File
@@ -249,6 +249,9 @@
error={refreshError ? 'Updates paused' : ''}
/>
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
{#if report.unreadCount}<Button href="/moderation?view=cases&review=updates"
>Your case updates ({report.unreadCount})</Button
>{/if}
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
>Restrict player</Button
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
@@ -339,6 +342,7 @@
caseStatus: filters.caseStatus ?? null,
priority: filters.priority ?? null,
assignedTo: filters.assignedTo ?? null,
review: filters.review ?? null,
offset: null
});
}}
@@ -364,6 +368,18 @@
>
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
{#if report.query.view === 'cases'}
<Field label="Review queue"
><Select
name="review"
bind:value={filters.review}
options={[
{ value: '', label: 'All cases' },
{ value: 'due', label: 'Overdue reviews' },
{ value: 'scheduled', label: 'Scheduled reviews' },
{ value: 'updates', label: 'Your unread updates' }
]}
/></Field
>
<Field label="Case status"
><Select
name="caseStatus"
@@ -418,7 +434,8 @@
matchId: null,
caseStatus: null,
priority: null,
assignedTo: null
assignedTo: null,
review: null
})}>Reset</Button
>
</form>
@@ -597,6 +614,31 @@
description="Restrictions matching these filters will appear here, including expiry and revocation history."
/>{/if}
{:else if report.query.view === 'cases'}
{#if report.notifications?.length}
<section class="record" aria-label="Your case notifications">
<h2>Your case updates ({report.unreadCount ?? report.notifications.length})</h2>
<ul class="notification-list">
{#each report.notifications as notification (notification.id)}
<li>
<a href={moderationCaseHref(notification.caseId)}
>{notification.caseTitle}</a
>
· {notification.kind === 'evidence'
? 'Evidence or note added'
: 'Assignment changed'} · <RelativeTime
value={notification.createdAt}
/>
</li>
{/each}
</ul>
<p class="hint">Open a case to review and mark its updates read.</p>
</section>
{/if}
<div class="view-actions">
<Button href="/moderation?view=cases&review=due">Overdue reviews</Button><Button
href="/moderation?view=cases&review=updates">Your unread updates</Button
>
</div>
{#if permitted}<div class="view-actions">
<Button onclick={() => openDialog('case')}>Open case</Button>
</div>{/if}
@@ -628,6 +670,27 @@
{/each}
</div>
{:else}<p class="hint">Not assessed</p>{/if}
{#if item.reviewAt}<div class="record-heading">
<Badge
variant={Date.parse(item.reviewAt) <=
Date.parse(report.checkedAt)
? 'warning'
: 'info'}
>{Date.parse(item.reviewAt) <= Date.parse(report.checkedAt)
? 'Overdue review'
: 'Review scheduled'}</Badge
>
<time datetime={item.reviewAt}
>{$timeFormat.dateTime(item.reviewAt)}</time
>
</div>{/if}
{#if item.unreadUpdates}<p>
<Badge variant="info"
>{item.unreadUpdates} unread {item.unreadUpdates === 1
? 'update'
: 'updates'}</Badge
>
</p>{/if}
<dl>
<div>
<dt>Priority</dt>
@@ -900,4 +963,10 @@
line-clamp: 3;
overflow: hidden;
}
.notification-list {
margin: 0;
padding-left: var(--space-5);
display: grid;
gap: var(--space-2);
}
</style>
@@ -33,6 +33,8 @@
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
import CaseAssessment from '$ui/components/moderation/CaseAssessment.svelte';
import CaseRelationships from '$ui/components/moderation/CaseRelationships.svelte';
import CaseFollowUp from '$ui/components/moderation/CaseFollowUp.svelte';
import CaseUpdates from '$ui/components/moderation/CaseUpdates.svelte';
import { preservedConversation } from '$ui/moderation-evidence.js';
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
@@ -396,6 +398,27 @@
</div>
<aside class="case-sidebar">
<CaseFollowUp
{item}
actions={report.linkedActions}
checkedAt={report.checkedAt}
{permitted}
onsubmitted={() => {
notice = 'Follow-up review updated.';
cancelRefresh();
void refresh();
}}
/>
<CaseUpdates
caseId={item.id}
notifications={report.notifications}
unread={item.unreadUpdates}
{permitted}
onsubmitted={() => {
cancelRefresh();
void refresh();
}}
/>
<CaseRelationships
caseId={item.id}
revision={item.revision}
+226
View File
@@ -0,0 +1,226 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import CaseFollowUp from '../../src/lib/ui/components/moderation/CaseFollowUp.svelte';
import CaseUpdates from '../../src/lib/ui/components/moderation/CaseUpdates.svelte';
import type {
ModerationCase,
ModerationCaseAction,
ModerationCaseNotification
} 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 item: ModerationCase = {
id: 'case-1',
game: 't5',
guid: '123',
title: 'Chat review',
status: 'resolved',
priority: 'normal',
assignee: null,
assigneeId: null,
actor: 'Moderator One',
createdAt: '2026-10-16T10:00:00Z',
updatedAt: '2026-10-16T10:00:00Z',
revision: 6,
evidence: []
};
const mute: ModerationCaseAction = {
id: 'mute-1',
type: 'restriction',
kind: 'mute',
reason: 'Earlier chat abuse',
actor: 'Moderator One',
createdAt: '2026-10-16T10:00:00Z',
state: 'active',
expiresAt: '2026-10-17T10:00:00Z'
};
function show(initial = item, permitted = true) {
const saved = vi.fn();
component = mount(CaseFollowUp, {
target: document.body,
props: {
item: initial,
actions: [mute, { ...mute, id: 'permanent', expiresAt: null }],
checkedAt: '2026-10-17T12:00:00Z',
permitted,
onsubmitted: saved
}
});
flushSync();
return saved;
}
const button = (label: string, dialog = false) =>
[...document.querySelectorAll(dialog ? 'dialog button' : 'button')].find(
(node) => node.textContent?.trim() === label
)! as HTMLButtonElement;
function field<T extends HTMLInputElement | 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<HTMLInputElement | 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 });
it('schedules a closed case using a browser-local date converted to canonical ISO', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
button('Schedule review').click();
await tick();
enter('Review date (browser local time)', '2026-10-19T14:30');
enter('Follow-up reason', 'Check for additional reports');
button('Save follow-up').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'schedule-case-review',
id: 'case-1',
revision: 6,
reviewAt: new Date('2026-10-19T14:30').toISOString(),
reason: 'Check for additional reports',
idempotencyKey: expect.any(String)
});
});
it('uses the linked finite mute ID to schedule an exact expiration review', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
button('Schedule review').click();
await tick();
expect(
[...field<HTMLSelectElement>('Review timing').options].map((option) => option.value)
).toEqual(['custom', 'mute-1']);
select('Review timing', 'mute-1');
enter('Follow-up reason', 'Review after mute expires');
button('Save follow-up').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
const payload = JSON.parse(fetch.mock.calls[0][1].body);
expect(payload).toMatchObject({
op: 'schedule-case-review',
restrictionId: 'mute-1',
revision: 6
});
expect(payload).not.toHaveProperty('reviewAt');
});
it('shows overdue reviews and clears the date with a recorded result', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show({
...item,
reviewAt: '2026-10-17T10:00:00Z',
reviewReason: 'Check once the mute ends',
reviewSanctionId: 'mute-1'
});
expect(document.body.textContent).toContain('Overdue review');
expect(document.body.textContent).toContain('Review after linked mute expires.');
button('Complete or clear review').click();
await tick();
enter('Review result or clearing reason', 'Review complete; no further reports');
button('Complete or clear review', true).click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'schedule-case-review',
id: 'case-1',
revision: 6,
reviewAt: null,
reason: 'Review complete; no further reports'
});
});
it('shows overdue follow-up details without edit controls for read-only viewers', () => {
show({ ...item, reviewAt: '2026-10-17T10:00:00Z', reviewReason: 'Review reports' }, false);
expect(document.body.textContent).toContain('Overdue review');
expect(document.body.textContent).toContain('Review reports');
expect(button('Reschedule review')).toBeUndefined();
expect(button('Complete or clear review')).toBeUndefined();
});
const notification = (id: number): ModerationCaseNotification => ({
id,
caseId: 'case-1',
caseTitle: item.title,
kind: id === 3 ? 'assignment' : 'evidence',
createdAt: '2026-10-17T10:00:00Z',
readAt: null
});
it('acknowledges only loaded updates and preserves the read-through ID on an uncertain retry', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const values = writable([notification(3), notification(9)]);
const state = fromStore(values);
const saved = vi.fn();
component = mount(CaseUpdates, {
target: document.body,
props: {
caseId: 'case-1',
get notifications() {
return state.current;
},
unread: 2,
permitted: true,
onsubmitted: saved
}
});
flushSync();
expect(document.body.textContent).toContain('Assignment changed');
expect(document.body.textContent).toContain('Evidence or note added');
button('Mark updates as seen').click();
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
values.set([notification(12), notification(9), notification(3)]);
flushSync();
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);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'read-case-updates',
id: 'case-1',
throughId: 9,
idempotencyKey: expect.any(String)
});
});
it('does not offer notification acknowledgement to read-only viewers', () => {
component = mount(CaseUpdates, {
target: document.body,
props: {
caseId: 'case-1',
notifications: [notification(3)],
unread: 1,
permitted: false,
onsubmitted: vi.fn()
}
});
flushSync();
expect(document.body.textContent).toContain('1 unread');
expect(button('Mark updates as seen')).toBeUndefined();
});
+48 -3
View File
@@ -324,13 +324,21 @@ it('opens a newly created case page after preserving selected evidence', async (
it('links case records to workspaces and preserves case filters while paging', () => {
const data = overview('cases');
data.query = { ...data.query, caseStatus: 'active', priority: 'high', assignedTo: 'me' };
data.query = {
...data.query,
caseStatus: 'active',
priority: 'high',
assignedTo: 'me',
review: 'due'
};
data.cases = [
{
id: 'case-1',
game: 't5',
guid,
title: 'Chat investigation',
reviewAt: '2026-10-14T12:00:00Z',
unreadUpdates: 2,
assessment: {
revision: 3,
summary: 'The threat remains ambiguous.',
@@ -357,7 +365,7 @@ it('links case records to workspaces and preserves case filters while paging', (
}
];
data.total = 40;
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me');
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me&review=due');
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
'Chat investigation'
);
@@ -374,9 +382,46 @@ it('links case records to workspaces and preserves case filters while paging', (
expect(destination.searchParams.get('caseStatus')).toBe('active');
expect(destination.searchParams.get('priority')).toBe('high');
expect(destination.searchParams.get('assignedTo')).toBe('me');
expect(destination.searchParams.get('review')).toBe('due');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Overdue review');
expect(document.querySelector('#moderation-results')?.textContent).toContain(
'2 unread updates'
);
expect(destination.searchParams.get('offset')).toBe('25');
button('Reset').click();
const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'offset'])
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'review', 'offset'])
expect(reset.searchParams.has(filter)).toBe(false);
});
it('shows personal evidence and assignment notifications with links into the actionable queue', () => {
const data = overview('cases');
data.unreadCount = 2;
data.notifications = [
{
id: 1,
caseId: 'follow-up',
caseTitle: 'Review context',
kind: 'evidence',
createdAt: data.checkedAt,
readAt: null
},
{
id: 2,
caseId: 'appeal',
caseTitle: 'Review appeal',
kind: 'assignment',
createdAt: data.checkedAt,
readAt: null
}
];
show(data);
const notifications = document.querySelector('[aria-label="Your case notifications"]')!;
expect(notifications.textContent).toContain('Evidence or note added');
expect(notifications.textContent).toContain('Assignment changed');
expect(notifications.querySelector('a[href="/moderation/cases/follow-up"]')).not.toBeNull();
expect(
document.querySelector('a[href="/moderation?view=cases&review=updates"]')
).not.toBeNull();
expect(document.querySelector('a[href="/moderation?view=cases&review=due"]')).not.toBeNull();
});
+1 -1
View File
@@ -255,7 +255,7 @@ it('adds assessment storage to an existing database without altering case record
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);
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
const insert = legacy.prepare(
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
);
@@ -287,7 +287,9 @@ it('upgrades relationship storage with foreign keys, symmetric identity and acti
const before = legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toEqual(before);
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toMatchObject(
before
);
const insert = legacy.prepare(
'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)'
);
+395
View File
@@ -0,0 +1,395 @@
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
const future = '2026-10-17T13: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 [id, role] of [
['creator', 'moderator'],
['owner', 'moderator'],
['next', 'moderator'],
['admin', 'admin'],
['viewer', 'viewer'],
['reader', 'super_viewer']
])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(id, id, id, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'creator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'creator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
async function overview(actor = 'creator', params = '') {
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
return moderationOverview(actor, moderationQuery(new URLSearchParams(`view=cases&${params}`)));
}
const create = (overrides: Record<string, unknown> = {}) =>
mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review incident',
...overrides
});
const schedule = (id: string, revision: number, reviewAt: unknown = future) => ({
op: 'schedule-case-review',
id,
revision,
reviewAt,
reason: 'Review the case after the restriction ends.'
});
const restriction = (overrides: Record<string, unknown> = {}) =>
mutate(
{
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'mute',
scope: 'game',
durationMinutes: 60,
reason: 'Earlier moderation action',
...overrides
},
'admin'
);
it('schedules canonical dates, preserves them through closure and reopening, and clears them with an audit', async () => {
const { id } = await create();
for (const value of [
'',
'2026-10-17',
'2026-02-30T12:00:00.000Z',
'2026-10-17T12:00:00Z',
'2026-10-17T08:00:00.000-04:00',
12,
'+010000-01-01T00:00:00.000Z'
])
await expect(mutate(schedule(id, 1, value))).rejects.toMatchObject({ status: 400 });
await expect(mutate({ ...schedule(id, 1), restrictionId: 'another' })).rejects.toMatchObject({
status: 400
});
for (const actor of ['viewer', 'reader'])
await expect(mutate(schedule(id, 1), actor)).rejects.toMatchObject({ status: 403 });
await mutate(schedule(id, 1));
await expect(mutate(schedule(id, 1))).rejects.toMatchObject({ status: 409 });
const first = await detail(id);
expect(first.case).toMatchObject({
revision: 2,
reviewAt: future,
reviewSanctionId: null,
reviewReason: schedule(id, 1).reason
});
await mutate(schedule(id, 2));
expect(await detail(id)).toEqual(first);
await mutate({
op: 'resolve-case',
id,
revision: 2,
status: 'no_action',
reason: 'Review complete for now'
});
expect((await detail(id)).case.reviewAt).toBe(future);
await mutate({ op: 'reopen-case', id, revision: 3, reason: 'New context arrived' });
expect((await detail(id)).case.reviewAt).toBe(future);
await mutate(schedule(id, 4, null));
const cleared = await detail(id);
expect(cleared.case).toMatchObject({ revision: 5, reviewAt: null, reviewSanctionId: null });
expect(JSON.parse(cleared.events[0].detail)).toEqual({
reviewAt: null,
reason: schedule(id, 1).reason,
restrictionId: null
});
});
it('derives dates only from linked finite mutes and supports already expired restrictions without reissuing them', async () => {
const { id } = await create();
const mute = await restriction({ caseId: id, caseRevision: 1 });
const unlinked = await restriction();
const permanent = await restriction({ caseId: id, caseRevision: 2, durationMinutes: null });
const ban = await restriction({ caseId: id, caseRevision: 3, kind: 'ban' });
const other = await restriction({ guid: '456' });
const input = (restrictionId: string) => ({
op: 'schedule-case-review',
id,
revision: 4,
restrictionId,
reason: 'Check after mute expires'
});
await expect(mutate(input(unlinked.id))).rejects.toThrow('Link the mute');
for (const target of [permanent.id, ban.id])
await expect(mutate(input(target))).rejects.toThrow('recorded expiry');
await expect(mutate(input(other.id))).rejects.toMatchObject({ status: 404 });
const before = handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all();
const policy = handle.prepare('SELECT * FROM moderation_policy').all();
vi.setSystemTime(new Date('2026-10-17T14:00:00.000Z'));
await mutate(input(mute.id));
expect((await detail(id)).case).toMatchObject({
reviewAt: future,
reviewSanctionId: mute.id,
revision: 5
});
expect(handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all()).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_policy').all()).toEqual(policy);
});
it('prioritizes overdue closed cases and recalculates due and scheduled queues using the current clock', async () => {
const closed = await create({ title: 'Closed follow-up' });
const active = await create({ title: 'Urgent active case', priority: 'urgent' });
const upcoming = await create({ title: 'Upcoming review' });
await mutate({
op: 'resolve-case',
id: closed.id,
revision: 1,
status: 'no_action',
reason: 'Initial review complete'
});
await mutate(schedule(closed.id, 2, clock));
await mutate(schedule(upcoming.id, 1, future));
expect((await overview()).cases.map((item) => item.id)).toEqual([
closed.id,
active.id,
upcoming.id
]);
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
closed.id
]);
expect((await overview('creator', 'review=scheduled')).total).toBe(2);
expect((await overview('creator', 'review=invalid')).query.review).toBe('');
vi.setSystemTime(new Date(future));
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
closed.id,
upcoming.id
]);
expect((await overview()).cases[0].status).toBe('no_action');
await mutate(schedule(closed.id, 3, null));
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
upcoming.id
]);
});
it('notifies distinct active assignees and the creator of assignment changes, excluding the acting staff member', async () => {
const request = {
op: 'create-case',
game: 't5',
guid: '123',
title: 'Assigned review',
assigneeId: 'owner',
idempotencyKey: randomUUID()
};
const { id } = await mutate(request);
await mutate(request);
expect((await detail(id, 'owner')).notifications).toMatchObject([{ kind: 'assignment' }]);
expect((await detail(id, 'creator')).notifications).toEqual([]);
await mutate({ op: 'update-case', id, revision: 1, assigneeId: 'next' }, 'admin');
const recipients = handle
.prepare(
'SELECT user_id,COUNT(*) AS count FROM moderation_case_notifications GROUP BY user_id ORDER BY user_id'
)
.all();
expect(recipients).toEqual([
{ user_id: 'creator', count: 1 },
{ user_id: 'next', count: 1 },
{ user_id: 'owner', count: 2 }
]);
expect((await overview('admin')).unreadCount).toBe(0);
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='creator'").run();
handle.prepare("UPDATE auth_users SET role='viewer' WHERE id='next'").run();
await mutate({ op: 'update-case', id, revision: 2, assigneeId: 'owner' }, 'admin');
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_notifications').get()
).toEqual({ count: 5 });
await expect(
mutate({ op: 'update-case', id, revision: 3, assigneeId: 'owner' }, 'admin')
).rejects.toThrow('Change the case');
});
it('notifies once per fresh evidence batch, with no notifications for duplicate attachments or retries', async () => {
const { id } = await create({ assigneeId: 'owner' });
const matchId = Number(
handle
.prepare(
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES('removed-server','t5','map','map','Map',?,?,'game_start','start')`
)
.run(clock, clock).lastInsertRowid
);
for (let occurrence = 1; occurrence <= 19; occurrence++)
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
matchId,
'repeat',
occurrence,
JSON.stringify({
type: 'message',
message: {
time: '1:00',
guid: '123',
player: 'Player',
message: 'Repeated message'
},
context: { realTime: null, round: null, matchElapsedMs: null }
})
);
const request = {
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, message: 'Repeated message' },
idempotencyKey: randomUUID()
};
await mutate(request, 'admin');
await mutate(request, 'admin');
await mutate({ ...request, revision: 2, idempotencyKey: randomUUID() }, 'admin');
expect((await detail(id)).case.evidence).toHaveLength(19);
expect(
handle
.prepare(
"SELECT user_id,kind FROM moderation_case_notifications WHERE kind='evidence' ORDER BY user_id"
)
.all()
).toEqual([
{ user_id: 'creator', kind: 'evidence' },
{ user_id: 'owner', kind: 'evidence' }
]);
await mutate({ op: 'case-note', id, revision: 2, text: 'More context' }, 'owner');
expect((await detail(id, 'owner')).case.unreadUpdates).toBe(2);
expect((await detail(id, 'creator')).case.unreadUpdates).toBe(2);
expect((await overview('owner', 'review=updates')).cases.map((item) => item.id)).toEqual([id]);
expect((await overview('admin', 'review=updates')).total).toBe(0);
});
it('marks only the reader’s seen case notifications and preserves newer arrivals and other cases', async () => {
const { id } = await create({ assigneeId: 'owner' });
const other = await create({ assigneeId: 'owner' });
await mutate({ op: 'case-note', id, text: 'First new evidence' }, 'admin');
const seen = await detail(id, 'owner');
const throughId = Math.max(...seen.notifications!.map((entry) => entry.id));
await mutate({ op: 'case-note', id, text: 'Later evidence' }, 'admin');
const revision = (await detail(id)).case.revision;
const read = { op: 'read-case-updates', id, throughId, idempotencyKey: randomUUID() };
await mutate(read, 'owner');
await mutate(read, 'owner');
const data = await detail(id, 'owner');
expect(data.case).toMatchObject({ revision, unreadUpdates: 1 });
expect(data.notifications!.filter((entry) => !entry.readAt)).toHaveLength(1);
expect(data.notifications![0].id).toBeGreaterThan(throughId);
expect((await detail(id, 'creator')).case.unreadUpdates).toBe(2);
expect((await detail(other.id, 'owner')).case.unreadUpdates).toBe(1);
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, '2'])
await expect(
mutate({ ...read, throughId: invalid, idempotencyKey: randomUUID() }, 'owner')
).rejects.toMatchObject({ status: 400 });
await expect(mutate({ ...read, idempotencyKey: randomUUID() }, 'reader')).rejects.toMatchObject(
{ status: 403 }
);
});
it('returns bounded notification lists alongside complete personal unread counts', async () => {
const { id } = await create();
for (let index = 0; index < 30; index++)
handle
.prepare(
"INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('owner',?,'evidence',?)"
)
.run(id, clock);
const data = await detail(id, 'owner');
const queue = await overview('owner');
expect(data.case.unreadUpdates).toBe(30);
expect(data.notifications).toHaveLength(25);
expect(queue.unreadCount).toBe(30);
expect(queue.notifications).toHaveLength(25);
expect(queue.notifications![0].id).toBeGreaterThan(queue.notifications![24].id);
expect((await detail(id)).notifications).toEqual([]);
expect((await overview('creator')).notifications).toEqual([]);
});
it('rolls back schedules and evidence if their audit or notification write fails', async () => {
const { id } = await create({ assigneeId: 'owner' });
const before = await detail(id);
handle.exec(`CREATE TRIGGER fail_review_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.review_scheduled' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
await expect(mutate(schedule(id, 1))).rejects.toThrow('audit unavailable');
expect(await detail(id)).toEqual(before);
handle.exec('DROP TRIGGER fail_review_audit');
handle.exec(`CREATE TRIGGER fail_notification BEFORE INSERT ON moderation_case_notifications
BEGIN SELECT RAISE(ABORT,'notification unavailable'); END`);
const request = { op: 'case-note', id, text: 'New evidence', idempotencyKey: randomUUID() };
await expect(mutate(request, 'admin')).rejects.toThrow('notification unavailable');
expect(await detail(id)).toEqual(before);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
});
it('upgrades existing cases with unscheduled defaults and durable personal notification constraints', 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, 33));
legacy
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('case','t5','123','Existing case','open','former','Former staff',?,?)`
)
.run(clock, clock);
legacy
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('owner','owner','Owner','unused','moderator',0,0)`
)
.run();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(
legacy
.prepare(
'SELECT id,revision,review_at,review_reason,review_sanction_id FROM moderation_cases'
)
.get()
).toEqual({
id: 'case',
revision: 1,
review_at: null,
review_reason: '',
review_sanction_id: null
});
const insert = legacy.prepare(
'INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES(?,?,?,?)'
);
insert.run('owner', 'case', 'evidence', clock);
expect(() => insert.run('missing', 'case', 'evidence', clock)).toThrow('FOREIGN KEY');
expect(() => insert.run('owner', 'missing', 'evidence', clock)).toThrow('FOREIGN KEY');
expect(() => insert.run('owner', 'case', 'unknown', clock)).toThrow('CHECK');
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});