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
+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);
}