Link prior moderation actions to case decisions
This commit is contained in:
@@ -28,7 +28,11 @@ import { migrateBank } from './bank.js';
|
||||
import { migrateAfk } from './afk.js';
|
||||
import { migrateBankCredentials } from './bank-credentials.js';
|
||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||
import { migrateModeration, migrateModerationCases } from '../moderation/schema.js';
|
||||
import {
|
||||
migrateModeration,
|
||||
migrateModerationCases,
|
||||
migrateModerationCaseActions
|
||||
} from '../moderation/schema.js';
|
||||
import type { Migration } from './migration-runner.js';
|
||||
|
||||
// Append only: never renumber entries or change an applied migration to add an
|
||||
@@ -170,7 +174,12 @@ export const migrations: readonly Migration[] = [
|
||||
mode: 'foreign-keys-off',
|
||||
run: migrateModerationCases
|
||||
},
|
||||
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk }
|
||||
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk },
|
||||
{
|
||||
version: 31,
|
||||
name: 'moderation case links to existing actions',
|
||||
run: migrateModerationCaseActions
|
||||
}
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { archivedEvidenceSnapshots } from './evidence.js';
|
||||
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
||||
import {
|
||||
@@ -220,7 +221,13 @@ function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
||||
.run(now, id);
|
||||
advancePolicy();
|
||||
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
|
||||
if (row.case_id) touchCase(row.case_id, now);
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE moderation_cases SET updated_at=?,revision=revision+1
|
||||
WHERE id=? OR id IN (SELECT case_id FROM moderation_case_action_links
|
||||
WHERE action_type='restriction' AND action_id=?)`
|
||||
)
|
||||
.run(now, row.case_id, id);
|
||||
return { id };
|
||||
}
|
||||
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
@@ -465,6 +472,27 @@ 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 === 'link-case-action') {
|
||||
const row = caseRow(input.id);
|
||||
checkCaseRevision(row, input.revision);
|
||||
const reference = caseActionReference({ type: input.actionType, id: input.actionId });
|
||||
const reason = textField(input.reason, 'Link reason', 4000);
|
||||
recordedCaseAction(row, reference, now);
|
||||
if (isCaseActionLinked(row.id, reference)) return { id: row.id };
|
||||
db()
|
||||
.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)')
|
||||
.run(row.id, reference.type, reference.id, actor.username, now);
|
||||
touchCase(row.id, now);
|
||||
moderationEvent(
|
||||
actor,
|
||||
'case.action_linked',
|
||||
row,
|
||||
row.id,
|
||||
JSON.stringify({ actionReference: reference, reason }),
|
||||
now
|
||||
);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'case-note') {
|
||||
const row = caseRow(input.id);
|
||||
if (input.revision !== undefined) checkCaseRevision(row, input.revision);
|
||||
@@ -481,13 +509,43 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
||||
const outcome = moderationCaseOutcomes.find((status) => status === input.status);
|
||||
if (!outcome) throw new AuthError('Choose a case outcome.');
|
||||
const reason = textField(input.reason, 'Decision reason', 4000);
|
||||
const reference =
|
||||
input.actionReference === undefined || input.actionReference === null
|
||||
? null
|
||||
: caseActionReference(input.actionReference);
|
||||
if (reference) {
|
||||
const action = recordedCaseAction(row, reference, now);
|
||||
if (!isCaseActionLinked(row.id, reference))
|
||||
throw new AuthError(
|
||||
'Link the recorded action to this case before citing it.',
|
||||
409
|
||||
);
|
||||
if (
|
||||
outcome === 'warning_issued' &&
|
||||
(action.type !== 'action' ||
|
||||
action.kind !== 'warn' ||
|
||||
action.state !== 'confirmed')
|
||||
)
|
||||
throw new AuthError('Warning issued must reference a confirmed warning.');
|
||||
if (outcome === 'restriction_applied' && action.type !== 'restriction')
|
||||
throw new AuthError(
|
||||
'Restriction applied must reference a recorded restriction.'
|
||||
);
|
||||
}
|
||||
evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
||||
)
|
||||
.run(outcome, now, row.id);
|
||||
moderationEvent(actor, `case.${outcome}`, row, row.id, reason, now);
|
||||
moderationEvent(
|
||||
actor,
|
||||
`case.${outcome}`,
|
||||
row,
|
||||
row.id,
|
||||
reference ? JSON.stringify({ reason, actionReference: reference }) : reason,
|
||||
now
|
||||
);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'reopen-case') {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { textField } from './core.js';
|
||||
import type { ModerationCaseAction, ModerationGame } from '../../moderation.js';
|
||||
|
||||
type Subject = { game: ModerationGame; guid: string };
|
||||
export type CaseActionReference = Pick<ModerationCaseAction, 'type' | 'id'>;
|
||||
|
||||
export function caseActionReference(value: unknown): CaseActionReference {
|
||||
if (!value || typeof value !== 'object') throw new AuthError('Choose a recorded action.');
|
||||
const reference = value as Record<string, unknown>;
|
||||
if (reference.type !== 'action' && reference.type !== 'restriction')
|
||||
throw new AuthError('Choose a recorded warning, action, or restriction.');
|
||||
return { type: reference.type, id: textField(reference.id, 'Action ID', 128) };
|
||||
}
|
||||
|
||||
const restrictionColumns = `id,'restriction' AS type,kind,reason,actor,
|
||||
created_at AS createdAt,expires_at AS expiresAt,
|
||||
CASE WHEN revoked_at IS NOT NULL THEN 'revoked'
|
||||
WHEN expires_at IS NOT NULL AND expires_at<=? THEN 'expired' ELSE 'active' END AS state`;
|
||||
const actionColumns = `id,'action' AS type,kind,reason,actor,
|
||||
created_at AS createdAt,expires_at AS expiresAt,state`;
|
||||
|
||||
/** Only actual records for this account can become case evidence. */
|
||||
export function recordedCaseAction(
|
||||
subject: Subject,
|
||||
reference: CaseActionReference,
|
||||
now: string
|
||||
): ModerationCaseAction {
|
||||
const handle = db();
|
||||
const action =
|
||||
reference.type === 'restriction'
|
||||
? handle
|
||||
.prepare(
|
||||
`SELECT ${restrictionColumns} FROM moderation_sanctions
|
||||
WHERE id=? AND guid=? AND (game=? OR scope='fleet')`
|
||||
)
|
||||
.get(now, reference.id, subject.guid, subject.game)
|
||||
: handle
|
||||
.prepare(
|
||||
`SELECT ${actionColumns} FROM moderation_actions
|
||||
WHERE id=? AND guid=? AND game=?`
|
||||
)
|
||||
.get(reference.id, subject.guid, subject.game);
|
||||
if (!action)
|
||||
throw new AuthError('The recorded action is unavailable for this case’s player.', 404);
|
||||
return action as ModerationCaseAction;
|
||||
}
|
||||
|
||||
export function isCaseActionLinked(caseId: string, reference: CaseActionReference): boolean {
|
||||
return Boolean(
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM moderation_case_action_links
|
||||
WHERE case_id=? AND action_type=? AND action_id=?
|
||||
UNION ALL SELECT 1 FROM moderation_sanctions
|
||||
WHERE ?='restriction' AND id=? AND case_id=? LIMIT 1`
|
||||
)
|
||||
.get(caseId, reference.type, reference.id, reference.type, reference.id, caseId)
|
||||
);
|
||||
}
|
||||
|
||||
export function caseActionLists(caseId: string, subject: Subject, now: string) {
|
||||
const handle = db();
|
||||
const records = handle
|
||||
.prepare(
|
||||
`SELECT ${restrictionColumns},
|
||||
(case_id=? OR EXISTS (SELECT 1 FROM moderation_case_action_links l
|
||||
WHERE l.case_id=? AND l.action_type='restriction' AND l.action_id=moderation_sanctions.id)) AS linked
|
||||
FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet')
|
||||
UNION ALL SELECT ${actionColumns},
|
||||
EXISTS (SELECT 1 FROM moderation_case_action_links l
|
||||
WHERE l.case_id=? AND l.action_type='action' AND l.action_id=moderation_actions.id) AS linked
|
||||
FROM moderation_actions WHERE guid=? AND game=?
|
||||
ORDER BY createdAt DESC,id`
|
||||
)
|
||||
.all(
|
||||
now,
|
||||
caseId,
|
||||
caseId,
|
||||
subject.guid,
|
||||
subject.game,
|
||||
caseId,
|
||||
subject.guid,
|
||||
subject.game
|
||||
) as Array<ModerationCaseAction & { linked: number | null }>;
|
||||
const linkedActions: ModerationCaseAction[] = [];
|
||||
const availableActions: ModerationCaseAction[] = [];
|
||||
for (const { linked, ...record } of records)
|
||||
(linked ? linkedActions : availableActions).push(record);
|
||||
return { linkedActions, availableActions };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { db } from '../db/index.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { moderationActor, textField } from './core.js';
|
||||
import { moderationOverview } from './overview.js';
|
||||
import { caseActionLists } from './case-actions.js';
|
||||
import type {
|
||||
ModerationCase,
|
||||
ModerationCaseDetail,
|
||||
@@ -42,9 +43,11 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
||||
.prepare(
|
||||
`SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note,
|
||||
created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision
|
||||
FROM moderation_sanctions WHERE case_id=? ORDER BY created_at DESC,id`
|
||||
FROM moderation_sanctions WHERE case_id=? OR id IN
|
||||
(SELECT action_id FROM moderation_case_action_links WHERE case_id=? AND action_type='restriction')
|
||||
ORDER BY created_at DESC,id`
|
||||
)
|
||||
.all(caseId) as Omit<ModerationSanction, 'active'>[]
|
||||
.all(caseId, caseId) as Omit<ModerationSanction, 'active'>[]
|
||||
).map((restriction) => ({
|
||||
...restriction,
|
||||
active:
|
||||
@@ -55,9 +58,10 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
||||
.prepare(
|
||||
`SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt
|
||||
FROM moderation_events WHERE subject_id=? OR subject_id IN
|
||||
(SELECT id FROM moderation_sanctions WHERE case_id=?) ORDER BY id DESC`
|
||||
(SELECT id FROM moderation_sanctions WHERE case_id=?) OR subject_id IN
|
||||
(SELECT action_id FROM moderation_case_action_links WHERE case_id=?) ORDER BY id DESC`
|
||||
)
|
||||
.all(caseId, caseId) as ModerationEvent[];
|
||||
.all(caseId, caseId, caseId) as ModerationEvent[];
|
||||
const assignees = handle
|
||||
.prepare(
|
||||
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
|
||||
@@ -77,6 +81,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
||||
lastSeen: null
|
||||
},
|
||||
sanctions,
|
||||
...caseActionLists(caseId, row, overview.checkedAt),
|
||||
events,
|
||||
coverage: overview.coverage,
|
||||
assignees,
|
||||
|
||||
@@ -149,3 +149,19 @@ export function migrateModerationCases(handle: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id);
|
||||
`);
|
||||
}
|
||||
|
||||
/** Links record prior enforcement without changing its original case or issuing it again. */
|
||||
export function migrateModerationCaseActions(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS moderation_case_action_links (
|
||||
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||
action_type TEXT NOT NULL CHECK(action_type IN ('action','restriction')),
|
||||
action_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(case_id,action_type,action_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS moderation_case_action_source
|
||||
ON moderation_case_action_links(action_type,action_id);
|
||||
`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user