This commit is contained in:
@@ -27,7 +27,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||
import { migrateBank } from './bank.js';
|
||||
import { migrateBankCredentials } from './bank-credentials.js';
|
||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||
import { migrateModeration } from '../moderation/schema.js';
|
||||
import { migrateModeration, migrateModerationCases } from '../moderation/schema.js';
|
||||
import type { Migration } from './migration-runner.js';
|
||||
|
||||
// Append only: never renumber entries or change an applied migration to add an
|
||||
@@ -162,7 +162,13 @@ export const migrations: readonly Migration[] = [
|
||||
);
|
||||
}
|
||||
},
|
||||
{ version: 28, name: 'known login devices', run: migrateLoginDevices }
|
||||
{ version: 28, name: 'known login devices', run: migrateLoginDevices },
|
||||
{
|
||||
version: 29,
|
||||
name: 'moderation case workflow and linked restrictions',
|
||||
mode: 'foreign-keys-off',
|
||||
run: migrateModerationCases
|
||||
}
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -4,7 +4,12 @@ import { AuthError } from '../auth/index.js';
|
||||
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
||||
import {
|
||||
canRevokeModerationRestriction,
|
||||
isModerationCaseOpen,
|
||||
moderationCaseOutcomes,
|
||||
moderationCaseStatusLabels,
|
||||
moderationGuid,
|
||||
type ModerationCaseStatus,
|
||||
type ModerationCasePriority,
|
||||
type ModerationGame,
|
||||
type ModerationScope
|
||||
} from '../../moderation.js';
|
||||
@@ -34,8 +39,18 @@ type SanctionRow = {
|
||||
expires_at: string | null;
|
||||
revision: number;
|
||||
revoked_at: string | null;
|
||||
case_id: string | null;
|
||||
};
|
||||
type CaseRow = {
|
||||
id: string;
|
||||
game: ModerationGame;
|
||||
guid: string;
|
||||
revision: number;
|
||||
status: ModerationCaseStatus;
|
||||
title: string;
|
||||
priority: ModerationCasePriority;
|
||||
assignee_id: string | null;
|
||||
};
|
||||
type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: string };
|
||||
|
||||
function serverTarget(name: unknown, game?: ModerationGame) {
|
||||
const server = textField(name, 'Server', 255);
|
||||
@@ -61,6 +76,15 @@ function serverTarget(name: unknown, game?: ModerationGame) {
|
||||
|
||||
function createSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
const target = subject(input);
|
||||
const linkedCase =
|
||||
input.caseId === undefined || input.caseId === null ? null : caseRow(input.caseId);
|
||||
if (linkedCase) {
|
||||
checkCaseRevision(linkedCase, input.caseRevision);
|
||||
if (!isModerationCaseOpen(linkedCase.status))
|
||||
throw new AuthError('Reopen this case before applying a restriction.', 409);
|
||||
if (linkedCase.game !== target.game || linkedCase.guid !== target.guid)
|
||||
throw new AuthError('A case restriction must target the case’s game and player.');
|
||||
}
|
||||
if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.');
|
||||
if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope))
|
||||
throw new AuthError('Choose a restriction scope.');
|
||||
@@ -117,8 +141,8 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
||||
ensureSubject(target.game, target.guid);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at,case_id)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -133,7 +157,8 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
||||
actor.id,
|
||||
actor.username,
|
||||
now,
|
||||
expires
|
||||
expires,
|
||||
linkedCase?.id ?? null
|
||||
);
|
||||
advancePolicy();
|
||||
moderationEvent(
|
||||
@@ -147,10 +172,22 @@ function createSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
||||
server: server?.name ?? null,
|
||||
reason,
|
||||
expiresAt: expires,
|
||||
note
|
||||
note,
|
||||
caseId: linkedCase?.id ?? null
|
||||
}),
|
||||
now
|
||||
);
|
||||
if (linkedCase) {
|
||||
touchCase(linkedCase.id, now);
|
||||
moderationEvent(
|
||||
actor,
|
||||
'case.restriction',
|
||||
linkedCase,
|
||||
linkedCase.id,
|
||||
JSON.stringify({ restrictionId: id, kind: input.kind, scope: input.scope, reason }),
|
||||
now
|
||||
);
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
@@ -183,6 +220,7 @@ 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);
|
||||
return { id };
|
||||
}
|
||||
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
@@ -267,15 +305,43 @@ function caseRow(value: unknown): CaseRow {
|
||||
if (!row) throw new AuthError('Case not found.', 404);
|
||||
return row;
|
||||
}
|
||||
function checkCaseRevision(row: CaseRow, revision: unknown) {
|
||||
if (row.revision !== revision)
|
||||
throw new AuthError('This case changed. Refresh before trying again.', 409);
|
||||
}
|
||||
function touchCase(id: string, now: string) {
|
||||
db()
|
||||
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
|
||||
.run(now, id);
|
||||
}
|
||||
function casePriority(value: unknown): ModerationCasePriority {
|
||||
if (value !== 'low' && value !== 'normal' && value !== 'high' && value !== 'urgent')
|
||||
throw new AuthError('Choose low, normal, high, or urgent priority.');
|
||||
return value;
|
||||
}
|
||||
function caseAssignee(value: unknown): { id: string | null; username: string | null } {
|
||||
if (value === null) return { id: null, username: null };
|
||||
const id = textField(value, 'Assignee', 128);
|
||||
const user = db()
|
||||
.prepare(
|
||||
`SELECT id,username FROM auth_users
|
||||
WHERE id=? AND role IN ('admin','moderator') AND disabled=0 AND must_change_password=0`
|
||||
)
|
||||
.get(id) as { id: string; username: string } | undefined;
|
||||
if (!user) throw new AuthError('Choose an active administrator or moderator to own this case.');
|
||||
return user;
|
||||
}
|
||||
function evidence(
|
||||
actor: AuthUser,
|
||||
id: string,
|
||||
text: string,
|
||||
source: string,
|
||||
snapshot: string | null,
|
||||
now: string
|
||||
now: string,
|
||||
decision = false
|
||||
) {
|
||||
if (
|
||||
!decision &&
|
||||
(
|
||||
db()
|
||||
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
|
||||
@@ -291,93 +357,206 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
|
||||
const target = subject(input);
|
||||
const title = textField(input.title, 'Case title', 160);
|
||||
const note = textField(input.note, 'Case note', 4000, true);
|
||||
const priority = input.priority === undefined ? 'normal' : casePriority(input.priority);
|
||||
const assignee =
|
||||
input.assigneeId === undefined
|
||||
? { id: null, username: null }
|
||||
: caseAssignee(input.assigneeId);
|
||||
const id = randomUUID();
|
||||
ensureSubject(target.game, target.guid);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,?,?,?,'open',?,?,?,?)`
|
||||
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,priority,assignee_id) VALUES(?,?,?,?,'open',?,?,?,?,?,?)`
|
||||
)
|
||||
.run(id, target.game, target.guid, title, actor.id, actor.username, now, now);
|
||||
.run(
|
||||
id,
|
||||
target.game,
|
||||
target.guid,
|
||||
title,
|
||||
actor.id,
|
||||
actor.username,
|
||||
now,
|
||||
now,
|
||||
priority,
|
||||
assignee.id
|
||||
);
|
||||
if (note) evidence(actor, id, note, 'Staff note', null, now);
|
||||
if (input.evidence !== undefined) {
|
||||
const requested = input.evidence as { matchId: unknown; message: unknown };
|
||||
if (!requested || !Number.isSafeInteger(requested.matchId))
|
||||
throw new AuthError('Choose a recorded chat message.');
|
||||
const message = textField(requested.message, 'Chat message', 4096);
|
||||
const match = db()
|
||||
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
|
||||
.get(requested.matchId) as
|
||||
{ game: string; server: string; started_at: string } | undefined;
|
||||
if (!match || match.game !== target.game)
|
||||
throw new AuthError('The source match is unavailable.', 404);
|
||||
const entries = db()
|
||||
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
|
||||
.all(requested.matchId) as { entry: string }[];
|
||||
const found = entries
|
||||
.filter((row) => {
|
||||
const entry = JSON.parse(row.entry);
|
||||
return (
|
||||
entry.type === 'message' &&
|
||||
moderationGuid(target.game, entry.message.guid) === target.guid &&
|
||||
entry.message.message === message
|
||||
);
|
||||
})
|
||||
.slice(0, 10);
|
||||
if (!found.length)
|
||||
throw new AuthError(
|
||||
'The original chat message could not be verified in the archive.',
|
||||
409
|
||||
);
|
||||
for (const row of found)
|
||||
evidence(
|
||||
actor,
|
||||
id,
|
||||
message,
|
||||
`Archived chat · ${match.server} · match ${requested.matchId}`,
|
||||
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
|
||||
now
|
||||
);
|
||||
}
|
||||
if (input.evidence !== undefined) archivedEvidence(actor, id, target, input.evidence, now);
|
||||
moderationEvent(actor, 'case.opened', target, id, title, now);
|
||||
if (priority !== 'normal' || assignee.id)
|
||||
moderationEvent(
|
||||
actor,
|
||||
'case.updated',
|
||||
target,
|
||||
id,
|
||||
JSON.stringify({
|
||||
priority: { from: 'normal', to: priority },
|
||||
assignee: { from: null, to: assignee.username }
|
||||
}),
|
||||
now
|
||||
);
|
||||
return { id };
|
||||
}
|
||||
function archivedEvidence(
|
||||
actor: AuthUser,
|
||||
id: string,
|
||||
target: { game: ModerationGame; guid: string },
|
||||
value: unknown,
|
||||
now: string
|
||||
): number {
|
||||
const requested = value as { matchId: unknown; message: unknown };
|
||||
if (!requested || !Number.isSafeInteger(requested.matchId))
|
||||
throw new AuthError('Choose a recorded chat message.');
|
||||
textField(requested.message, 'Chat message', 4096);
|
||||
// Evidence must match the archived bytes, including intentional whitespace.
|
||||
const message = requested.message as string;
|
||||
const match = db()
|
||||
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
|
||||
.get(requested.matchId) as { game: string; server: string; started_at: string } | undefined;
|
||||
if (!match || match.game !== target.game)
|
||||
throw new AuthError('The source match is unavailable.', 404);
|
||||
const entries = db()
|
||||
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
|
||||
.all(requested.matchId) as { entry: string }[];
|
||||
const found = entries
|
||||
.filter((row) => {
|
||||
const entry = JSON.parse(row.entry);
|
||||
return (
|
||||
entry.type === 'message' &&
|
||||
moderationGuid(target.game, entry.message.guid) === target.guid &&
|
||||
entry.message.message === message
|
||||
);
|
||||
})
|
||||
.slice(0, 10);
|
||||
if (!found.length)
|
||||
throw new AuthError('The original chat message could not be verified in the archive.', 409);
|
||||
for (const row of found)
|
||||
evidence(
|
||||
actor,
|
||||
id,
|
||||
message,
|
||||
`Archived chat · ${match.server} · match ${requested.matchId}`,
|
||||
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
|
||||
now
|
||||
);
|
||||
return found.length;
|
||||
}
|
||||
function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
const row = caseRow(input.id);
|
||||
checkCaseRevision(row, input.revision);
|
||||
const title = input.title === undefined ? row.title : textField(input.title, 'Case title', 160);
|
||||
const priority = input.priority === undefined ? row.priority : casePriority(input.priority);
|
||||
const assignee =
|
||||
input.assigneeId === undefined || input.assigneeId === row.assignee_id
|
||||
? null
|
||||
: caseAssignee(input.assigneeId);
|
||||
const assigneeId = assignee ? assignee.id : row.assignee_id;
|
||||
const status = input.status === undefined ? row.status : input.status;
|
||||
if (
|
||||
input.status !== undefined &&
|
||||
status !== 'open' &&
|
||||
status !== 'in_review' &&
|
||||
status !== 'escalated'
|
||||
)
|
||||
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);
|
||||
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 };
|
||||
if (priority !== row.priority) changes.priority = { from: row.priority, to: priority };
|
||||
if (status !== row.status) changes.status = { from: row.status, to: status };
|
||||
if (assigneeId !== row.assignee_id) {
|
||||
const previous = row.assignee_id
|
||||
? (db().prepare('SELECT username FROM auth_users WHERE id=?').get(row.assignee_id) as
|
||||
{ username: string } | undefined)
|
||||
: null;
|
||||
changes.assignee = { from: previous?.username ?? null, to: assignee?.username ?? null };
|
||||
changes.assigneeId = { from: row.assignee_id, to: assigneeId };
|
||||
}
|
||||
if (!Object.keys(changes).length) throw new AuthError('Change the case details before saving.');
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE moderation_cases SET title=?,priority=?,assignee_id=?,status=?,updated_at=?,revision=revision+1 WHERE id=?`
|
||||
)
|
||||
.run(title, priority, assigneeId, status, now, row.id);
|
||||
moderationEvent(
|
||||
actor,
|
||||
'case.updated',
|
||||
row,
|
||||
row.id,
|
||||
JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }),
|
||||
now
|
||||
);
|
||||
return { id: row.id };
|
||||
}
|
||||
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
|
||||
return moderationMutation(actorId, input, (actor, now) => {
|
||||
if (input.op === 'create-sanction') return createSanction(actor, input, now);
|
||||
if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now);
|
||||
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 === 'case-note') {
|
||||
const row = caseRow(input.id);
|
||||
if (input.revision !== undefined) checkCaseRevision(row, input.revision);
|
||||
const text = textField(input.text, 'Note', 4000);
|
||||
evidence(actor, row.id, text, 'Staff note', null, now);
|
||||
db()
|
||||
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
|
||||
.run(now, row.id);
|
||||
touchCase(row.id, now);
|
||||
moderationEvent(actor, 'case.note', row, row.id, text, now);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'resolve-case') {
|
||||
const row = caseRow(input.id);
|
||||
if (row.revision !== input.revision || row.status !== 'open')
|
||||
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
|
||||
throw new AuthError('This case changed. Refresh before resolving it.', 409);
|
||||
if (input.status !== 'resolved' && input.status !== 'dismissed')
|
||||
throw new AuthError('Choose resolved or dismissed.');
|
||||
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);
|
||||
evidence(
|
||||
actor,
|
||||
row.id,
|
||||
reason,
|
||||
input.status === 'resolved' ? 'Resolution' : 'Dismissal',
|
||||
null,
|
||||
now
|
||||
);
|
||||
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(input.status, now, row.id);
|
||||
moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now);
|
||||
.run(outcome, now, row.id);
|
||||
moderationEvent(actor, `case.${outcome}`, row, row.id, reason, now);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'reopen-case') {
|
||||
const row = caseRow(input.id);
|
||||
checkCaseRevision(row, input.revision);
|
||||
if (isModerationCaseOpen(row.status))
|
||||
throw new AuthError('This case is already active.', 409);
|
||||
const reason = textField(input.reason, 'Reopening reason', 4000);
|
||||
evidence(actor, row.id, reason, 'Reopened', null, now, true);
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE moderation_cases SET status='open',updated_at=?,revision=revision+1 WHERE id=?"
|
||||
)
|
||||
.run(now, row.id);
|
||||
moderationEvent(
|
||||
actor,
|
||||
'case.reopened',
|
||||
row,
|
||||
row.id,
|
||||
JSON.stringify({ from: row.status, reason }),
|
||||
now
|
||||
);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'attach-evidence') {
|
||||
const row = caseRow(input.id);
|
||||
checkCaseRevision(row, input.revision);
|
||||
const count = archivedEvidence(actor, row.id, row, input.evidence, now);
|
||||
touchCase(row.id, now);
|
||||
moderationEvent(
|
||||
actor,
|
||||
'case.evidence_attached',
|
||||
row,
|
||||
row.id,
|
||||
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
|
||||
now
|
||||
);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'note') {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { moderationActor, textField } from './core.js';
|
||||
import { moderationOverview } from './overview.js';
|
||||
import type {
|
||||
ModerationCase,
|
||||
ModerationCaseDetail,
|
||||
ModerationEvent,
|
||||
ModerationSanction
|
||||
} from '../../moderation.js';
|
||||
|
||||
/** A case owns its evidence and timeline independently of the paginated queues. */
|
||||
export function moderationCaseDetail(actorId: string, id: string): ModerationCaseDetail {
|
||||
moderationActor(actorId);
|
||||
const caseId = textField(id, 'Case ID', 128);
|
||||
return db().transaction(() => {
|
||||
const handle = db();
|
||||
const row = handle
|
||||
.prepare(
|
||||
`SELECT c.id,c.game,c.guid,c.title,c.status,c.priority,
|
||||
c.assignee_id AS assigneeId,u.username AS assignee,c.actor,c.created_at AS createdAt,
|
||||
c.updated_at AS updatedAt,c.revision FROM moderation_cases c
|
||||
LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?`
|
||||
)
|
||||
.get(caseId) as Omit<ModerationCase, 'evidence'> | undefined;
|
||||
if (!row) throw new AuthError('Case not found.', 404);
|
||||
const overview = moderationOverview(actorId, {
|
||||
view: 'players',
|
||||
q: '',
|
||||
game: row.game,
|
||||
guid: row.guid,
|
||||
offset: 0
|
||||
});
|
||||
const evidence = handle
|
||||
.prepare(
|
||||
`SELECT id,text,source,snapshot,actor,created_at AS createdAt
|
||||
FROM moderation_evidence WHERE case_id=? ORDER BY created_at,rowid`
|
||||
)
|
||||
.all(caseId) as ModerationCase['evidence'];
|
||||
const sanctions = (
|
||||
handle
|
||||
.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`
|
||||
)
|
||||
.all(caseId) as Omit<ModerationSanction, 'active'>[]
|
||||
).map((restriction) => ({
|
||||
...restriction,
|
||||
active:
|
||||
!restriction.revokedAt &&
|
||||
(!restriction.expiresAt || restriction.expiresAt > overview.checkedAt)
|
||||
}));
|
||||
const events = handle
|
||||
.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`
|
||||
)
|
||||
.all(caseId, caseId) as ModerationEvent[];
|
||||
const assignees = handle
|
||||
.prepare(
|
||||
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
|
||||
AND disabled=0 AND must_change_password=0 ORDER BY username COLLATE NOCASE,id`
|
||||
)
|
||||
.all() as ModerationCaseDetail['assignees'];
|
||||
return {
|
||||
case: { ...row, evidence },
|
||||
player: overview.players[0] ?? {
|
||||
game: row.game,
|
||||
guid: row.guid,
|
||||
name: row.guid,
|
||||
aliases: [],
|
||||
servers: [],
|
||||
restrictions: 0,
|
||||
firstSeen: null,
|
||||
lastSeen: null
|
||||
},
|
||||
sanctions,
|
||||
events,
|
||||
coverage: overview.coverage,
|
||||
assignees,
|
||||
checkedAt: overview.checkedAt
|
||||
};
|
||||
})();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { moderationActor, moderationNow, policyRevision } from './core.js';
|
||||
import { runtimeFreshMs } from './actions.js';
|
||||
import { refreshActionStates } from './runtime.js';
|
||||
import {
|
||||
moderationCaseStatuses,
|
||||
moderationGuid,
|
||||
type ModerationOverview,
|
||||
type ModerationQuery,
|
||||
@@ -19,12 +20,21 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
|
||||
const game = params.get('game');
|
||||
const guid = (params.get('guid') ?? '').slice(0, 128);
|
||||
const offset = Number(params.get('offset') ?? 0);
|
||||
const caseStatus = params.get('caseStatus') ?? '';
|
||||
const priority = params.get('priority') ?? '';
|
||||
return {
|
||||
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
|
||||
q: (params.get('q') ?? '').trim().slice(0, 200),
|
||||
game: game === 't5' || game === 't6' ? game : '',
|
||||
guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid,
|
||||
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0
|
||||
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0,
|
||||
caseStatus: ['active', 'closed', ...moderationCaseStatuses].includes(caseStatus)
|
||||
? (caseStatus as ModerationQuery['caseStatus'])
|
||||
: '',
|
||||
priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
|
||||
? (priority as ModerationQuery['priority'])
|
||||
: '',
|
||||
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128)
|
||||
};
|
||||
}
|
||||
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
|
||||
@@ -93,6 +103,25 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
||||
where.push('p.guid=?');
|
||||
values.push(query.guid);
|
||||
}
|
||||
if (query.view === 'cases') {
|
||||
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
|
||||
where.push(
|
||||
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review','escalated')`
|
||||
);
|
||||
} else if (query.caseStatus) {
|
||||
where.push('p.status=?');
|
||||
values.push(query.caseStatus);
|
||||
}
|
||||
if (query.priority) {
|
||||
where.push('p.priority=?');
|
||||
values.push(query.priority);
|
||||
}
|
||||
if (query.assignedTo === 'unassigned') where.push('p.assignee_id IS NULL');
|
||||
else if (query.assignedTo) {
|
||||
where.push('p.assignee_id=?');
|
||||
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
|
||||
}
|
||||
}
|
||||
if (query.q) {
|
||||
const extra =
|
||||
query.view === 'restrictions'
|
||||
@@ -161,14 +190,14 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
||||
} else if (query.view === 'restrictions') {
|
||||
result.sanctions = rows<Omit<ModerationSanction, 'active'>>(
|
||||
'moderation_sanctions',
|
||||
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision',
|
||||
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision,p.case_id AS caseId',
|
||||
'p.created_at DESC,p.id'
|
||||
).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) }));
|
||||
} else if (query.view === 'cases') {
|
||||
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
|
||||
'moderation_cases',
|
||||
'p.id,p.game,p.guid,p.title,p.status,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
|
||||
'p.updated_at DESC,p.id'
|
||||
'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',
|
||||
"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"
|
||||
).map((c) => ({
|
||||
...c,
|
||||
evidence: handle
|
||||
|
||||
@@ -109,3 +109,43 @@ export function migrateModeration(handle: Database.Database): void {
|
||||
cursor = [last.game, last.guid];
|
||||
}
|
||||
}
|
||||
|
||||
/** The migration runner disables foreign keys before opening this transaction. */
|
||||
export function migrateModerationCases(handle: Database.Database): void {
|
||||
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
|
||||
if (!columns.some((column) => column.name === 'priority')) {
|
||||
if (handle.pragma('foreign_keys', { simple: true }))
|
||||
throw new Error('The moderation case migration requires foreign keys to be disabled');
|
||||
// Do not rename the existing parent: archived evidence must keep its
|
||||
// original case reference through the rebuild.
|
||||
handle.exec(`
|
||||
CREATE TABLE moderation_cases_expanded (
|
||||
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','escalated','resolved','dismissed',
|
||||
'warning_issued','restriction_applied','no_action','duplicate','appeal_upheld','appeal_denied')),
|
||||
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
|
||||
);
|
||||
INSERT INTO moderation_cases_expanded(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
|
||||
SELECT id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision FROM moderation_cases;
|
||||
DROP TABLE moderation_cases;
|
||||
ALTER TABLE moderation_cases_expanded RENAME TO moderation_cases;
|
||||
`);
|
||||
}
|
||||
if (
|
||||
!(handle.pragma('table_info(moderation_sanctions)') as { name: string }[]).some(
|
||||
(column) => column.name === 'case_id'
|
||||
)
|
||||
)
|
||||
handle.exec(
|
||||
'ALTER TABLE moderation_sanctions ADD COLUMN case_id TEXT REFERENCES moderation_cases(id) ON DELETE SET NULL'
|
||||
);
|
||||
handle.exec(`
|
||||
CREATE INDEX IF NOT EXISTS moderation_cases_queue ON moderation_cases(status,priority,updated_at);
|
||||
CREATE INDEX IF NOT EXISTS moderation_cases_assignee ON moderation_cases(assignee_id,updated_at);
|
||||
CREATE INDEX IF NOT EXISTS moderation_sanctions_case ON moderation_sanctions(case_id);
|
||||
CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id);
|
||||
`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user