Separate moderation case status from decisions
CI / checks (push) Failing after 7m24s

This commit is contained in:
2026-10-17 21:30:43 -04:00
parent 48653af3dc
commit a7f1c65d17
22 changed files with 357 additions and 222 deletions
+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}