Compare commits

..
1 Commits
Author SHA1 Message Date
josh a7f1c65d17 Separate moderation case status from decisions
CI / checks (push) Failing after 7m24s
2026-10-17 21:30:43 -04:00
22 changed files with 357 additions and 222 deletions
+17 -13
View File
@@ -132,18 +132,22 @@ priority and assign it to an eligible administrator or moderator. Filter the cas
queue by status, priority, or assignment; active cases appear first, ordered by
priority and most recent update.
Move active cases between **Open**, **In review**, and **Escalated**, recording a
reason for workflow changes. Close a case with one of these outcomes: **Resolved**,
**Dismissed**, **Warning issued**, **Restriction applied**, **No action needed**,
**Duplicate**, **Appeal upheld**, or **Appeal denied**. Every decision requires a
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 **Change status** to move a case between **Open**, **In progress**, and
**Closed**, recording a reason for each transition. Assignment is separate:
a case can have an owner at any stage. To resume a closed case, change its status
to Open or In progress. Evidence, assessments, actions, and earlier decisions
remain in its history. Editing case details does not change its status.
Existing closed outcomes are migrated to Closed. Previously escalated cases
become In progress. Their previous statuses are recorded in history; existing
decision reasons, action references, assignments, and follow-ups are preserved.
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.
the evidence; case status tracks whether work remains. Record the decision and
its rationale in the assessment summary.
Every saved assessment retains its author, timestamp, and complete previous
revision. Assessments can be revised after closure without reopening the case.
@@ -174,9 +178,9 @@ 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
restriction leaves the case active so staff can finish reviewing it. Selecting a
closure outcome records a decision; it does not itself send a warning, create a
restriction, or revoke one. Linked restrictions can be revoked separately with
restriction leaves the case active so staff can finish reviewing it. Changing case status
records workflow progress; it does not send a warning, create a restriction, or
revoke one. Linked restrictions can be revoked separately with
the required permission and reason, including after a case closes. Reopening a
case also leaves its restrictions in place. Check enforcement coverage after
applying or revoking a restriction.
@@ -186,9 +190,9 @@ mute already recorded for this player with the case. This preserves the original
action, author, time, delivery state, and expiry; it does not issue another action
or extend a restriction. The same action can support more than one related case.
Linking requires the current case revision and a reason, and appears in its timeline.
When closing with **Warning issued** or **Restriction applied**, optionally reference
one of the linked records. Warning references require a confirmed warning; failed
or uncertain delivery remains visible without being presented as a confirmed warning.
Linked records show what actually happened, including failed or uncertain delivery.
They remain visible when a case closes; status changes do not require choosing
or re-entering an action.
Staff notes are explicitly labeled as notes. In match chat, select up to 100
messages and choose **Attach to case** to open a case or add them to an existing
+9 -9
View File
@@ -6,6 +6,7 @@ export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
export type ModerationState =
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
export const moderationCaseOutcomes = [
'resolved',
'dismissed',
@@ -16,12 +17,7 @@ export const moderationCaseOutcomes = [
'appeal_upheld',
'appeal_denied'
] as const;
export const moderationCaseStatuses = [
'open',
'in_review',
'escalated',
...moderationCaseOutcomes
] as const;
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
@@ -31,9 +27,13 @@ export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string
appeal_of: 'Appeal of',
duplicate_of: 'Duplicate of'
};
export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = {
export const moderationCaseStatusLabels: Record<
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
string
> = {
open: 'Open',
in_review: 'In review',
in_review: 'In progress',
closed: 'Closed',
escalated: 'Escalated',
resolved: 'Resolved',
dismissed: 'Dismissed',
@@ -45,7 +45,7 @@ export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> =
appeal_denied: 'Appeal denied'
};
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
return status === 'open' || status === 'in_review' || status === 'escalated';
return status === 'open' || status === 'in_review';
}
export const moderationConcernCategories = [
+8 -1
View File
@@ -35,7 +35,8 @@ import {
migrateModerationCaseAssessments,
migrateModerationCaseRelationships,
migrateModerationCaseFollowups,
migrateModerationCaseReferenceIndexes
migrateModerationCaseReferenceIndexes,
migrateModerationCaseStates
} from '../moderation/schema.js';
import type { Migration } from './migration-runner.js';
@@ -203,6 +204,12 @@ export const migrations: readonly Migration[] = [
version: 35,
name: 'moderation case foreign key reference indexes',
run: migrateModerationCaseReferenceIndexes
},
{
version: 36,
name: 'moderation case lifecycle states',
mode: 'foreign-keys-off',
run: migrateModerationCaseStates
}
];
+5 -7
View File
@@ -11,6 +11,7 @@ import {
canRevokeModerationRestriction,
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationCaseStatus,
type ModerationCasePriority,
@@ -438,13 +439,9 @@ function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string
const status = input.status === undefined ? row.status : input.status;
if (
input.status !== undefined &&
status !== 'open' &&
status !== 'in_review' &&
status !== 'escalated'
!moderationCaseStatuses.includes(status as ModerationCaseStatus)
)
throw new AuthError('Choose an active case status, or close the case with an outcome.');
if (input.status !== undefined && !isModerationCaseOpen(row.status))
throw new AuthError('Use reopen to reactivate a closed case.', 409);
throw new AuthError('Choose Open, In progress, or Closed.');
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
const changes: Record<string, unknown> = {};
if (title !== row.title) changes.title = { from: row.title, to: title };
@@ -585,6 +582,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
notifyCase(actor.id, row.id, 'evidence', now);
return { id: row.id };
}
// Older clients may still record decisions, but every closure now stores only Closed.
if (input.op === 'resolve-case') {
const row = caseRow(input.id);
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
@@ -620,7 +618,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
.prepare(
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
)
.run(outcome, now, row.id);
.run('closed', now, row.id);
moderationEvent(
actor,
`case.${outcome}`,
+2 -2
View File
@@ -114,7 +114,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
if (query.view === 'cases') {
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
where.push(
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review','escalated')`
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review')`
);
} else if (query.caseStatus) {
where.push('p.status=?');
@@ -220,7 +220,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
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,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",
"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') 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,
+39
View File
@@ -235,3 +235,42 @@ export function migrateModerationCaseReferenceIndexes(handle: Database.Database)
ON moderation_case_notifications(case_id);
`);
}
/** Normalize lifecycle state while retaining every prior decision and child record. */
export function migrateModerationCaseStates(handle: Database.Database): void {
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The case status migration requires foreign keys to be disabled');
const objects = handle
.prepare(
`SELECT sql FROM sqlite_master
WHERE tbl_name='moderation_cases' AND type IN ('index','trigger') AND sql IS NOT NULL`
)
.all() as { sql: string }[];
// Capture old statuses even for imported cases without a corresponding decision event.
handle.exec(`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
SELECT game,guid,'Maxis','case.status_migrated',id,
json_object('from',status,'to',CASE WHEN status='escalated' THEN 'in_review' ELSE 'closed' END),
strftime('%Y-%m-%dT%H:%M:%fZ','now') FROM moderation_cases
WHERE status NOT IN ('open','in_review');
CREATE TABLE moderation_cases_state (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','in_review','closed')),
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL,
review_at TEXT, review_reason TEXT NOT NULL DEFAULT '',
review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL
);
INSERT INTO moderation_cases_state
SELECT id,game,guid,title,
CASE WHEN status IN ('open','in_review') THEN status
WHEN status='escalated' THEN 'in_review' ELSE 'closed' END,
actor_id,actor,created_at,updated_at,
revision+CASE WHEN status IN ('open','in_review') THEN 0 ELSE 1 END,
priority,assignee_id,review_at,review_reason,review_sanction_id FROM moderation_cases;
DROP TABLE moderation_cases;
ALTER TABLE moderation_cases_state RENAME TO moderation_cases;
`);
for (const object of objects) handle.exec(object.sql);
}
@@ -1,17 +1,14 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationCoverage,
type ModerationGame,
type ModerationScope,
type ModerationCase,
type ModerationCaseAction
type ModerationCase
} from '$lib/moderation.js';
import { moderationActionName, moderationGameName } from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { moderationGameName } from '$ui/moderation.js';
import { serverTitle } from '$ui/server-labels.js';
import { confirmAction } from '$ui/confirm.js';
import Dialog from '../primitives/Dialog.svelte';
@@ -29,7 +26,6 @@
admin,
seed = {},
assignees = [],
linkedActions = [],
onsubmitted
}: {
open?: boolean;
@@ -40,14 +36,12 @@
| 'note'
| 'revoke'
| 'case-note'
| 'resolve'
| 'reopen'
| 'case-status'
| 'edit-case'
| 'evidence';
coverage: ModerationCoverage[];
admin: boolean;
assignees?: { id: string; username: string }[];
linkedActions?: ModerationCaseAction[];
seed?: {
game?: ModerationGame;
guid?: string;
@@ -81,26 +75,10 @@
let status = $state(untrack(() => seed.status ?? 'open'));
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
let message = $state(untrack(() => seed.message ?? ''));
const statusUnchanged = $derived(mode === 'case-status' && status === seed.status);
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
const stateChanged = $derived(mode === 'edit-case' && status !== seed.status);
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action');
let resolution = $state('resolved');
let actionReference = $state('');
const referenceActions = $derived(
linkedActions.filter((item) =>
resolution === 'warning_issued'
? item.type === 'action' && item.kind === 'warn' && item.state === 'confirmed'
: resolution === 'restriction_applied' && item.type === 'restriction'
)
);
const referencedAction = $derived(
referenceActions.find((item) => `${item.type}:${item.id}` === actionReference)
);
$effect(() => {
void resolution;
actionReference = '';
});
let busy = $state(false);
let error = $state('');
let uncertain = $state(false);
@@ -113,8 +91,7 @@
note: 'Add private note',
revoke: 'Revoke restriction',
'case-note': 'Add case note',
resolve: 'Review case',
reopen: 'Reopen case',
'case-status': 'Change status',
'edit-case': 'Edit case',
evidence: 'Attach archived evidence'
}[mode]
@@ -184,13 +161,6 @@
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
if (mode === 'case-note')
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
if (mode === 'reopen')
return {
op: 'reopen-case',
id: seed.id,
revision: seed.revision,
reason: reason.trim()
};
if (mode === 'edit-case')
return {
op: 'update-case',
@@ -201,7 +171,6 @@
...(assigneeId !== (seed.assigneeId ?? '')
? { assigneeId: assigneeId || null }
: {}),
...(isModerationCaseOpen(status) ? { status } : {}),
reason: reason.trim()
};
if (mode === 'evidence')
@@ -219,14 +188,11 @@
reason: reason.trim()
};
return {
op: 'resolve-case',
op: 'update-case',
id: seed.id,
revision: seed.revision,
status: resolution,
reason: reason.trim(),
...(referencedAction
? { actionReference: { type: referencedAction.type, id: referencedAction.id } }
: {})
status,
reason: reason.trim()
};
}
@@ -392,7 +358,7 @@
</p>{/if}
{#if lockedTarget}<p class="hint">
This restriction will be linked to the case and its recorded player identity.
Applying it does not close the case; record the outcome when your review is
Applying it does not close the case; change its status when your work is
complete.
</p>{/if}
{#if mode === 'case' || mode === 'edit-case'}
@@ -447,26 +413,8 @@
/></Field
>
</div>
{#if isModerationCaseOpen(seed.status ?? 'open')}
<Field label="Workflow status"
><Select
bind:value={status}
options={[
{ value: 'open', label: moderationCaseStatusLabels.open },
{ value: 'in_review', label: moderationCaseStatusLabels.in_review },
{ value: 'escalated', label: moderationCaseStatusLabels.escalated }
]}
/></Field
>
{:else}<p class="hint">
The recorded outcome is preserved. Reopen the case to resume review.
</p>{/if}
<Field
label="Change reason"
required={stateChanged}
hint="Required when changing workflow status."
>
<Textarea bind:value={reason} required={stateChanged} rows={3} />
<Field label="Change reason" hint="Optional note about these edits.">
<Textarea bind:value={reason} rows={3} />
</Field>
{/if}
{#if mode === 'evidence'}
@@ -481,55 +429,29 @@
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
>
{/if}
{#if mode === 'resolve'}<Field label="Outcome"
{#if mode === 'case-status'}
<Field label="Status"
><Select
bind:value={resolution}
options={moderationCaseOutcomes.map((value) => ({
bind:value={status}
options={moderationCaseStatuses.map((value) => ({
value,
label: moderationCaseStatusLabels[value]
}))}
/></Field
>{/if}
{#if mode === 'resolve'}<p class="hint">
This records the decision and closes the case. Apply or revoke restrictions
separately, and send warnings through Live action. Choosing an outcome does not
change enforcement or send a warning.
</p>{/if}
{#if mode === 'resolve' && ['warning_issued', 'restriction_applied'].includes(resolution)}
<Field
label="Supporting action"
hint="Optional. Reference an existing linked action as the basis for this outcome."
>
<Select
bind:value={actionReference}
options={[
{ value: '', label: 'No action reference' },
...referenceActions.map((item) => ({
value: `${item.type}:${item.id}`,
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
}))
]}
/>
</Field>
{#if !referenceActions.length}<p class="hint">
{resolution === 'warning_issued'
? 'No confirmed warnings are linked to this case.'
: 'No restrictions are linked to this case.'} Use “Link existing action” on
the case to add a prior action.
</p>{/if}
<p class="hint">
Track whether this case is open, in progress, or closed. Findings and recorded
actions stay in the case history.
</p>
{/if}
{#if mode === 'reopen'}<p class="hint">
Reopening returns the case to Open and preserves its evidence, restrictions, and
earlier decisions.
</p>{/if}
{#if ['restriction', 'action', 'revoke', 'resolve', 'reopen'].includes(mode)}
{#if ['restriction', 'action', 'revoke', 'case-status'].includes(mode)}
<Field
label={mode === 'action' && ['message', 'announce'].includes(action)
? 'Message'
: mode === 'restriction' || mode === 'action'
? 'Player-visible reason'
: mode === 'reopen'
? 'Reopening reason'
: mode === 'case-status'
? 'Change reason'
: 'Review reason'}
hint={gameText
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
@@ -561,7 +483,7 @@
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
disabled={!uncertain && actionUnavailable}
disabled={!uncertain && (actionUnavailable || statusUnchanged)}
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
>
+4 -1
View File
@@ -70,6 +70,7 @@ export const moderationActionLabels: Record<string, string> = {
'case.appeal_denied': 'Appeal denied',
'case.reopened': 'Case reopened',
'case.updated': 'Case updated',
'case.status_migrated': 'Case status migrated',
'case.assessed': 'Assessment revised',
'case.review_scheduled': 'Follow-up updated',
'schedule-case-review': 'Follow-up updated',
@@ -137,8 +138,10 @@ export function moderationEventDetail(detail: string, action: string): string {
if (typeof value.reason === 'string') changes.push(value.reason);
return changes.join('\n') || detail;
}
if (action === 'case.status_migrated')
return `Previous status: ${display(value.from, true)} → ${display(value.to, true)}. Earlier decisions and actions are preserved.`;
if (action === 'case.reopened' && typeof value.reason === 'string')
return `Previous outcome: ${display(value.from, true)}\n${value.reason}`;
return `Previous status: ${display(value.from, true)}\n${value.reason}`;
if (
action === 'case.evidence_attached' &&
typeof value.count === 'number' &&
+2 -1
View File
@@ -23,7 +23,8 @@ const states: Record<string, [string, StatusTone]> = {
'moderation-active': ['Active', 'warning'],
'moderation-revoked': ['Revoked', 'neutral'],
'moderation-open': ['Open', 'info'],
'moderation-in_review': ['In review', 'info'],
'moderation-in_review': ['In progress', 'info'],
'moderation-closed': ['Closed', 'neutral'],
'moderation-escalated': ['Escalated', 'warning'],
'moderation-resolved': ['Resolved', 'online'],
'moderation-dismissed': ['Dismissed', 'neutral'],
-1
View File
@@ -388,7 +388,6 @@
options={[
{ value: '', label: 'All statuses' },
{ value: 'active', label: 'Active cases' },
{ value: 'closed', label: 'Closed cases' },
...moderationCaseStatuses.map((status) => ({
value: status,
label: moderationCaseStatusLabels[status]
@@ -150,7 +150,7 @@
function submitted() {
notice =
mode === 'restriction'
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you record an outcome.'
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you change its status.'
: mode === 'revoke'
? 'Revocation saved. Servers apply it when they receive the latest policy.'
: 'Case updated. The change is recorded in the timeline.';
@@ -226,12 +226,9 @@
/>
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
{#if permitted}
{#if active}<Button variant="primary" onclick={() => openDialog('resolve')}
>Review case</Button
>
{:else}<Button variant="primary" onclick={() => openDialog('reopen')}
>Reopen case</Button
>{/if}
<Button variant="primary" onclick={() => openDialog('case-status')}
>Change status</Button
>
{/if}
{/snippet}
</SectionHeader>
@@ -568,7 +565,7 @@
</li>{/each}
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
<p class="hint">
Applying or revoking a restriction does not change the case outcome.
Applying or revoking a restriction does not change the case status.
</p>
</section>
@@ -604,7 +601,6 @@
{admin}
coverage={report.coverage}
assignees={report.assignees}
linkedActions={report.linkedActions}
onsubmitted={submitted}
/>{/key}{/if}
+51 -53
View File
@@ -211,7 +211,7 @@ it('preserves original evidence context, linked restrictions, and staff history
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
for (const label of [
'Edit case',
'Review case',
'Change status',
'Reopen case',
'Restrict player',
'Revoke restriction',
@@ -251,12 +251,12 @@ it('locks case restrictions to the recorded identity and revision without closin
durationMinutes: 60
});
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
expect(button('Review case')).toBeDefined();
expect(button('Change status')).toBeDefined();
});
it('records specific outcomes and reopens a closed case with a reason and the latest revision', async () => {
it('closes and reopens through lifecycle status with a reason and the latest revision', async () => {
const closed = detail();
closed.case.status = 'warning_issued';
closed.case.status = 'closed';
closed.case.revision = 5;
const reopened = detail();
reopened.case.status = 'open';
@@ -269,37 +269,34 @@ it('records specific outcomes and reopens a closed case with a reason and the la
.mockResolvedValueOnce(response(reopened));
vi.stubGlobal('fetch', fetch);
show();
button('Review case').click();
button('Change status').click();
await tick();
expect([...field<HTMLSelectElement>('Outcome').options].map((option) => option.value)).toEqual([
'resolved',
'dismissed',
'warning_issued',
'restriction_applied',
'no_action',
'duplicate',
'appeal_upheld',
'appeal_denied'
expect([...field<HTMLSelectElement>('Status').options].map((option) => option.value)).toEqual([
'open',
'in_review',
'closed'
]);
select('Outcome', 'warning_issued');
enter(field('Review reason'), 'Warning was sufficient');
select('Status', 'closed');
enter(field('Change reason'), 'Warning was sufficient');
submit();
await vi.waitFor(() => expect(button('Reopen case')).toBeDefined());
await vi.waitFor(() => expect(document.querySelector('dialog')?.open).not.toBe(true));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'resolve-case',
op: 'update-case',
id: 'case-1',
revision: 4,
status: 'warning_issued',
status: 'closed',
reason: 'Warning was sufficient'
});
expect(button('Restrict player')).toBeUndefined();
button('Reopen case').click();
button('Change status').click();
await tick();
enter(field('Reopening reason'), 'New evidence received');
select('Status', 'open');
enter(field('Change reason'), 'New evidence received');
submit();
await vi.waitFor(() => expect(button('Review case')).toBeDefined());
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4));
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
op: 'reopen-case',
op: 'update-case',
status: 'open',
id: 'case-1',
revision: 5,
reason: 'New evidence received'
@@ -307,7 +304,7 @@ it('records specific outcomes and reopens a closed case with a reason and the la
expect(document.body.textContent).toContain('Check the earlier warning.');
});
it('edits priority, assignment, and review workflow with a required transition reason', async () => {
it('edits priority and assignment separately from lifecycle status', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
@@ -319,8 +316,8 @@ it('edits priority, assignment, and review workflow with a required transition r
enter(field('Case title'), 'Urgent report');
select('Priority', 'urgent');
select('Assigned to', 'staff-2');
select('Workflow status', 'escalated');
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(true);
expect(document.querySelector('dialog')?.textContent).not.toContain('Workflow status');
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(false);
enter(field('Change reason'), 'Needs administrator review');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
@@ -331,14 +328,13 @@ it('edits priority, assignment, and review workflow with a required transition r
title: 'Urgent report',
priority: 'urgent',
assigneeId: 'staff-2',
status: 'escalated',
reason: 'Needs administrator review'
});
});
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
const initial = detail();
initial.case.status = 'no_action';
initial.case.status = 'closed';
initial.case.assigneeId = 'disabled-staff';
initial.case.assignee = 'Former staff';
const fetch = vi
@@ -431,13 +427,11 @@ it('links a prior mute with its recorded outcome without issuing another restric
expect(button('Link existing action').disabled).toBe(true);
});
it.each([
['warning_issued', 'action:warning-1', 'action', 'warning-1'],
['restriction_applied', 'restriction:restriction-1', 'restriction', 'restriction-1']
] as const)(
'references a linked action when recording %s',
async (outcome, selection, type, id) => {
it.each(['in_review', 'closed'] as const)(
'changes status to %s without replacing findings or issuing an action',
async (status) => {
const initial = detail();
initial.case.status = 'open';
initial.linkedActions = [
{
id: 'warning-1',
@@ -476,31 +470,34 @@ it.each([
.mockResolvedValue(response(initial));
vi.stubGlobal('fetch', fetch);
show(initial);
button('Review case').click();
button('Change status').click();
await tick();
select('Outcome', outcome);
const options = [...field<HTMLSelectElement>('Supporting action').options].map(
(option) => option.value
);
expect(options).toEqual(['', selection]);
select('Supporting action', selection);
enter(field('Review reason'), 'Reviewed the recorded action');
expect(button('Save').disabled).toBe(true);
select('Status', status);
await tick();
expect(button('Save').disabled).toBe(false);
expect(document.querySelector('dialog')?.textContent).not.toContain('Supporting action');
enter(field('Change reason'), 'Reviewed the recorded action');
submit();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'resolve-case',
op: 'update-case',
id: 'case-1',
revision: 4,
status: outcome,
actionReference: { type, id }
status
});
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('actionReference');
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
'Confirmed warning'
);
}
);
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
const updated = detail();
updated.case.revision = 8;
updated.case.status = 'escalated';
updated.case.title = 'Updated case after concurrent edit';
updated.case.status = 'in_review';
const fetch = vi
.fn()
.mockResolvedValueOnce(
@@ -509,28 +506,29 @@ it('retains a conflicted review and lets staff refresh before making another dec
.mockResolvedValue(response(updated));
vi.stubGlobal('fetch', fetch);
show();
button('Review case').click();
button('Change status').click();
await tick();
select('Outcome', 'appeal_denied');
enter(field('Review reason'), 'Evidence supports the restriction');
select('Status', 'closed');
enter(field('Change reason'), 'Evidence supports the restriction');
submit();
await vi.waitFor(() =>
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
'This case changed.'
)
);
expect(field<HTMLSelectElement>('Outcome').value).toBe('appeal_denied');
expect(field<HTMLSelectElement>('Status').value).toBe('closed');
expect(document.querySelector('dialog')!.open).toBe(true);
expect(button('Reopen case')).toBeUndefined();
button('Cancel').click();
await tick();
button('Refresh').click();
await vi.waitFor(() =>
expect(document.querySelector('.case-summary')?.textContent).toContain('Escalated')
expect(document.querySelector('h1')?.textContent).toBe('Updated case after concurrent edit')
);
button('Review case').click();
button('Change status').click();
await tick();
enter(field('Review reason'), 'Reviewed new context');
select('Status', 'closed');
enter(field('Change reason'), 'Reviewed new context');
submit();
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
+1 -1
View File
@@ -21,7 +21,7 @@ const item: ModerationCase = {
game: 't5',
guid: '123',
title: 'Chat review',
status: 'resolved',
status: 'closed',
priority: 'normal',
assignee: null,
assigneeId: null,
+2 -2
View File
@@ -353,7 +353,7 @@ it('links case records to workspaces and preserves case filters while paging', (
actor: 'Moderator',
createdAt: '2026-10-16T12:00:00Z'
},
status: 'escalated',
status: 'in_review',
priority: 'high',
assigneeId: 'staff-1',
assignee: 'Moderator',
@@ -369,7 +369,7 @@ it('links case records to workspaces and preserves case filters while paging', (
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
'Chat investigation'
);
expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated');
expect(document.querySelector('#moderation-results')?.textContent).toContain('In progress');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
expect(document.querySelector('#moderation-results')?.textContent).toContain(
'The threat remains ambiguous.'
+3 -3
View File
@@ -94,7 +94,7 @@ it('preserves current and historical assessments with case revisions, including
};
await mutate(assess(id, 3, updated), 'admin');
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ revision: 4, status: 'no_action' });
expect(data.case).toMatchObject({ revision: 4, status: 'closed' });
expect(data.assessment).toEqual({
...updated,
revision: 4,
@@ -253,8 +253,8 @@ it('adds assessment storage to an existing database without altering case record
)
.run(clock, clock);
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
runMigrations(legacy, migrations.slice(0, 32));
runMigrations(legacy, migrations.slice(0, 32));
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
const insert = legacy.prepare(
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
+3 -3
View File
@@ -164,7 +164,7 @@ it('links historical live actions to closed cases and exposes their real deliver
const before = enforcement();
await mutate(link(id, 2, 'action', warningId));
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({ status: 'no_action', revision: 3 });
expect(data.case).toMatchObject({ status: 'closed', revision: 3 });
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
@@ -246,7 +246,7 @@ it('requires a linked confirmed warning for a cited warning outcome and preserve
const before = enforcement();
await mutate(resolve(5, 'action', confirmed));
const data = await detail(id);
expect(data.case).toMatchObject({ status: 'warning_issued', revision: 6 });
expect(data.case).toMatchObject({ status: 'closed', revision: 6 });
expect(JSON.parse(data.events[0].detail)).toEqual({
reason: 'Decision after evidence review',
actionReference: { type: 'action', id: confirmed }
@@ -268,7 +268,7 @@ it('allows a historical expired restriction created from the original case to su
reason: 'Earlier mute addressed the incident',
actionReference: { type: 'restriction', id: sanction.id }
});
expect((await detail(id)).case.status).toBe('restriction_applied');
expect((await detail(id)).case.status).toBe('closed');
expect(enforcement()).toEqual(before);
});
+3 -3
View File
@@ -26,8 +26,8 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
for (const [title, priority, status, owner] of [
['Normal report', 'normal', 'open', null],
['Urgent review', 'urgent', 'in_review', 'first'],
['High escalation', 'high', 'escalated', 'second'],
['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first']
['High escalation', 'high', 'in_review', 'second'],
['Closed urgent appeal', 'urgent', 'closed', 'first']
]) {
const { id } = mutateModeration('first', {
op: 'create-case',
@@ -46,7 +46,7 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
expect(read('caseStatus=active').total).toBe(3);
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
expect(read('caseStatus=escalated&priority=high').cases.map((item) => item.id)).toEqual([
expect(read('caseStatus=in_review&priority=high').cases.map((item) => item.id)).toEqual([
ids[2]
]);
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
@@ -156,7 +156,7 @@ it('enforces case existence, staff access, revisions, self-link rejection and su
reason: 'Review complete'
});
await mutate(link(a.id, 2, b.id));
expect((await detail(a.id)).case).toMatchObject({ status: 'no_action', revision: 3 });
expect((await detail(a.id)).case).toMatchObject({ status: 'closed', revision: 3 });
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
});
+157
View File
@@ -0,0 +1,157 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { moderationCaseOutcomes, moderationCaseStatuses } from '../../src/lib/moderation.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => fixture.cleanup());
it('migrates every legacy state while preserving assignments, decisions, evidence, actions and relationships', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const handle = new Database(':memory:');
handle.pragma('foreign_keys = ON');
try {
runMigrations(handle, migrations.slice(0, 35));
handle.exec(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('staff','staff','Staff','unused','moderator',0,0)`);
const statuses = ['open', 'in_review', 'escalated', ...moderationCaseOutcomes];
for (const status of statuses) {
handle
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision,assignee_id,review_at)
VALUES(?,'t5','123','Existing case',?,'staff','Staff','2026-10-17','2026-10-17',7,'staff','2026-11-01')`
)
.run(status, status);
}
handle.exec(`
INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
VALUES('t5','123','Staff','case.warning_issued','warning_issued','Original decision','2026-10-17');
INSERT INTO moderation_evidence VALUES('e','warning_issued','Original excerpt','Archived chat','{"entry":"unchanged"}','staff','Staff','2026-10-17');
INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
VALUES('s','t5','123','mute','game','Original action','','staff','Staff','2026-10-17','warning_issued');
UPDATE moderation_cases SET review_sanction_id='s' WHERE id='warning_issued';
INSERT INTO moderation_case_action_links VALUES('warning_issued','restriction','s','Staff','2026-10-17');
INSERT INTO moderation_case_relationships VALUES('r','open','warning_issued','same_incident','staff','Staff','2026-10-17',NULL);
INSERT INTO moderation_case_assessments VALUES('warning_issued',6,'{"summary":"Existing finding"}','staff','Staff','2026-10-17');
INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('staff','warning_issued','evidence','2026-10-17');
`);
const children = [
'moderation_evidence',
'moderation_sanctions',
'moderation_case_action_links',
'moderation_case_relationships',
'moderation_case_assessments',
'moderation_case_notifications'
];
const before = children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all());
runMigrations(handle, migrations);
runMigrations(handle, migrations);
expect(children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all())).toEqual(
before
);
for (const old of statuses) {
const expected =
old === 'open'
? 'open'
: ['in_review', 'escalated'].includes(old)
? 'in_review'
: 'closed';
expect(
handle
.prepare(
'SELECT status,revision,assignee_id,review_at FROM moderation_cases WHERE id=?'
)
.get(old)
).toEqual({
status: expected,
revision: old === expected ? 7 : 8,
assignee_id: 'staff',
review_at: '2026-11-01'
});
}
const events = handle
.prepare("SELECT detail FROM moderation_events WHERE action='case.status_migrated'")
.all() as { detail: string }[];
expect(events.map((event) => JSON.parse(event.detail).from).sort()).toEqual(
['escalated', ...moderationCaseOutcomes].sort()
);
expect(
handle
.prepare("SELECT detail FROM moderation_events WHERE action='case.warning_issued'")
.get()
).toEqual({ detail: 'Original decision' });
expect(() =>
handle.exec("UPDATE moderation_cases SET status='warning_issued' WHERE id='open'")
).toThrow('CHECK');
expect(handle.pragma('foreign_key_check')).toEqual([]);
} finally {
handle.close();
}
});
it('changes lifecycle independently of ownership, findings and enforcement with revision and retry protection', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
const handle = db();
for (const role of ['moderator', 'super_viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
const mutate = (input: Record<string, unknown>, actor = 'moderator') =>
mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
const { id } = mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Reported conduct',
assigneeId: 'moderator'
});
mutate({
op: 'assess-case',
id,
revision: 1,
summary: 'Ambiguous context',
rules: [],
findings: [{ category: 'threats', finding: 'inconclusive', detail: 'Need more context' }]
});
const original = moderationCaseDetail('moderator', id);
let revision = 2;
for (const status of ['in_review', 'closed', 'open', 'closed', 'in_review']) {
const request = {
op: 'update-case',
id,
revision,
status,
reason: 'Workflow changed',
idempotencyKey: randomUUID()
};
expect(() => mutate({ ...request, reason: '' })).toThrow('Status change reason');
expect(() => mutate({ ...request, revision: revision - 1 })).toThrow('changed');
expect(() => mutate(request, 'super_viewer')).toThrow('Moderation access');
mutate(request);
mutate(request);
revision++;
const data = moderationCaseDetail('moderator', id);
expect(data.case).toMatchObject({ status, revision, assigneeId: 'moderator' });
expect(data.assessmentHistory).toEqual(original.assessmentHistory);
expect(data.case.evidence).toEqual(original.case.evidence);
expect(data.sanctions).toEqual(original.sanctions);
expect(data.events[0]).toMatchObject({ action: 'case.updated' });
}
for (const status of [...moderationCaseOutcomes, 'assigned', 'escalated'])
expect(() =>
mutate({ op: 'update-case', id, revision, status, reason: 'Invalid state' })
).toThrow('Choose Open');
expect(moderationCaseStatuses).toEqual(['open', 'in_review', 'closed']);
expect(handle.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get()).toEqual({ n: 0 });
});
+7 -7
View File
@@ -134,8 +134,8 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
.all()
).toEqual(
['dismissed', 'open', 'resolved'].map((status) => ({
status,
revision: 7,
status: status === 'open' ? 'open' : 'closed',
revision: status === 'open' ? 7 : 8,
priority: 'normal',
assignee_id: null
}))
@@ -146,7 +146,7 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
.get('{"original":true}')
).toEqual({ count: 3 });
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 3
count: 5
});
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
result: '{"ok":true,"id":"open"}'
@@ -245,12 +245,12 @@ it('audits title, ownership, priority and active state updates with optimistic r
id,
revision: 3,
assigneeId: null,
status: 'escalated',
status: 'open',
reason: 'Needs administrator review'
});
expect((await detail(id)).case).toMatchObject({
revision: 4,
status: 'escalated',
status: 'open',
assigneeId: null
});
await expect(
@@ -267,9 +267,9 @@ it('supports every outcome and reopening while preserving each prior decision',
);
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
revision++;
expect((await detail(id)).case.status).toBe(status);
expect((await detail(id)).case.status).toBe('closed');
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
'Use reopen'
'Status change reason'
);
await expect(
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
+1 -1
View File
@@ -185,7 +185,7 @@ it('prioritizes overdue closed cases and recalculates due and scheduled queues u
closed.id,
upcoming.id
]);
expect((await overview()).cases[0].status).toBe('no_action');
expect((await overview()).cases[0].status).toBe('closed');
await mutate(schedule(closed.id, 3, null));
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
upcoming.id
+13 -2
View File
@@ -95,14 +95,14 @@ it('presents case decisions and ownership changes as readable history while pres
'case.updated'
)
).toBe(
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In review\nReviewing additional evidence'
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In progress\nReviewing additional evidence'
);
expect(
moderationEventDetail(
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
'case.reopened'
)
).toBe('Previous outcome: Appeal denied\nNew evidence');
).toBe('Previous status: Appeal denied\nNew evidence');
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
'Decision with {unusual text}'
);
@@ -110,3 +110,14 @@ it('presents case decisions and ownership changes as readable history while pres
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
).toBe('2 archived chat entries attached from match #42.');
});
it('explains migrated status without hiding the historical decision', () => {
expect(
moderationEventDetail(
JSON.stringify({ from: 'warning_issued', to: 'closed' }),
'case.status_migrated'
)
).toBe(
'Previous status: Warning issued → Closed. Earlier decisions and actions are preserved.'
);
});