From a7f1c65d171402470d031759e07276d8100c2845 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 17 Oct 2026 21:30:43 -0400 Subject: [PATCH] Separate moderation case status from decisions --- docs/guides/moderation.md | 30 ++-- src/lib/moderation.ts | 18 +- src/lib/server/db/migrations.ts | 9 +- src/lib/server/moderation/actions.ts | 12 +- src/lib/server/moderation/overview.ts | 4 +- src/lib/server/moderation/schema.ts | 39 +++++ .../moderation/ModerationDialog.svelte | 126 +++----------- src/lib/ui/moderation.ts | 5 +- src/lib/ui/status.ts | 3 +- src/routes/moderation/+page.svelte | 1 - src/routes/moderation/cases/[id]/+page.svelte | 14 +- tests/client/moderation-case-page.test.ts | 104 ++++++------ tests/client/moderation-follow-up.test.ts | 2 +- tests/client/moderation-page.test.ts | 4 +- tests/unit/moderation-assessments.test.ts | 6 +- tests/unit/moderation-case-actions.test.ts | 6 +- tests/unit/moderation-case-overview.test.ts | 6 +- .../moderation-case-relationships.test.ts | 2 +- tests/unit/moderation-case-state.test.ts | 157 ++++++++++++++++++ tests/unit/moderation-cases.test.ts | 14 +- tests/unit/moderation-followups.test.ts | 2 +- tests/unit/ui-moderation.test.ts | 15 +- 22 files changed, 357 insertions(+), 222 deletions(-) create mode 100644 tests/unit/moderation-case-state.test.ts diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md index 85d5dc1..69f077b 100644 --- a/docs/guides/moderation.md +++ b/docs/guides/moderation.md @@ -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 diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index b493fe6..088e594 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -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 = { +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 = 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 = [ diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 36439f0..6485cc2 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -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 } ]; diff --git a/src/lib/server/moderation/actions.ts b/src/lib/server/moderation/actions.ts index ffe15de..90bc339 100644 --- a/src/lib/server/moderation/actions.ts +++ b/src/lib/server/moderation/actions.ts @@ -11,6 +11,7 @@ import { canRevokeModerationRestriction, isModerationCaseOpen, moderationCaseOutcomes, + moderationCaseStatuses, moderationCaseStatusLabels, type ModerationCaseStatus, type ModerationCasePriority, @@ -438,13 +439,9 @@ function updateCase(actor: AuthUser, input: Record, 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 = {}; if (title !== row.title) changes.title = { from: row.title, to: title }; @@ -585,6 +582,7 @@ export function mutateModeration(actorId: string, input: Record 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 .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}`, diff --git a/src/lib/server/moderation/overview.ts b/src/lib/server/moderation/overview.ts index 9e5f0ae..97458c0 100644 --- a/src/lib/server/moderation/overview.ts +++ b/src/lib/server/moderation/overview.ts @@ -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>( '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, diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts index 856439e..3049fb7 100644 --- a/src/lib/server/moderation/schema.ts +++ b/src/lib/server/moderation/schema.ts @@ -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); +} diff --git a/src/lib/ui/components/moderation/ModerationDialog.svelte b/src/lib/ui/components/moderation/ModerationDialog.svelte index aaa787b..1484d49 100644 --- a/src/lib/ui/components/moderation/ModerationDialog.svelte +++ b/src/lib/ui/components/moderation/ModerationDialog.svelte @@ -1,17 +1,14 @@