685 lines
27 KiB
TypeScript
685 lines
27 KiB
TypeScript
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 { assessmentDraft } from './assessments.js';
|
||
import { linkCases, unlinkCases } from './relationships.js';
|
||
import { notifyCase, caseReviewSchedule, readCaseUpdates } from './followups.js';
|
||
import { AuthError } from '../auth/index.js';
|
||
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
||
import {
|
||
canRevokeModerationRestriction,
|
||
isModerationCaseOpen,
|
||
moderationCaseOutcomes,
|
||
moderationCaseStatuses,
|
||
moderationCaseStatusLabels,
|
||
type ModerationCaseStatus,
|
||
type ModerationCasePriority,
|
||
type ModerationGame,
|
||
type ModerationScope
|
||
} from '../../moderation.js';
|
||
import {
|
||
advancePolicy,
|
||
ensureSubject,
|
||
moderationEvent,
|
||
moderationMutation,
|
||
subject,
|
||
textField
|
||
} from './core.js';
|
||
|
||
export const runtimeFreshMs = 15_000;
|
||
function gameText(value: unknown, label: string): string {
|
||
const text = textField(value, label, 240);
|
||
if (Buffer.byteLength(text, 'utf8') > 240)
|
||
throw new AuthError(`${label} must fit within 240 UTF-8 bytes for the game server.`);
|
||
return text;
|
||
}
|
||
type SanctionRow = {
|
||
id: string;
|
||
game: ModerationGame;
|
||
guid: string;
|
||
actor_id: string;
|
||
scope: ModerationScope;
|
||
created_at: string;
|
||
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;
|
||
review_at: string | null;
|
||
review_reason: string;
|
||
review_sanction_id: string | null;
|
||
};
|
||
|
||
function serverTarget(name: unknown, game?: ModerationGame) {
|
||
const server = textField(name, 'Server', 255);
|
||
const row = db()
|
||
.prepare(
|
||
`SELECT s.name,s.game,s.created_at,n.id AS instance_id,n.generation
|
||
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
|
||
WHERE s.name=? AND s.destroyed_at IS NULL`
|
||
)
|
||
.get(server) as
|
||
| {
|
||
name: string;
|
||
game: ModerationGame;
|
||
created_at: string;
|
||
instance_id: string | null;
|
||
generation: number | null;
|
||
}
|
||
| undefined;
|
||
if (!row || (game && row.game !== game))
|
||
throw new AuthError('Choose an existing server for this game.', 404);
|
||
return row;
|
||
}
|
||
|
||
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.');
|
||
const duration = input.durationMinutes;
|
||
if (
|
||
duration !== null &&
|
||
(!Number.isSafeInteger(duration) ||
|
||
(duration as number) < 1 ||
|
||
(duration as number) > 5256000)
|
||
)
|
||
throw new AuthError('Choose a duration between 1 minute and 10 years, or permanent.');
|
||
if (
|
||
actor.role !== 'admin' &&
|
||
(input.scope === 'fleet' ||
|
||
duration === null ||
|
||
(duration as number) * 60_000 > moderatorMaximumRestrictionMs)
|
||
)
|
||
throw new AuthError(
|
||
'Moderators can apply server or game restrictions lasting up to 7 days.',
|
||
403
|
||
);
|
||
// Fleet identity is only shared where both games have reported the same numeric account ID.
|
||
if (
|
||
input.scope === 'fleet' &&
|
||
(!/^\d+$/.test(target.guid) ||
|
||
(
|
||
db()
|
||
.prepare(
|
||
'SELECT COUNT(DISTINCT game) AS count FROM moderation_aliases WHERE guid=?'
|
||
)
|
||
.get(target.guid) as { count: number }
|
||
).count !== 2)
|
||
)
|
||
throw new AuthError(
|
||
'Fleet restrictions require this account ID to have been observed in both games. Use game scope until its identity is established.'
|
||
);
|
||
const reason = gameText(input.reason, 'Player-visible reason');
|
||
const note = textField(input.note, 'Private note', 4000, true);
|
||
const server = input.scope === 'server' ? serverTarget(input.server, target.game) : null;
|
||
const activeCount = (
|
||
db()
|
||
.prepare(
|
||
'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)'
|
||
)
|
||
.get(now) as { count: number }
|
||
).count;
|
||
if (activeCount >= 1000)
|
||
throw new AuthError('The limit of 1,000 active restrictions has been reached.', 409);
|
||
const id = randomUUID();
|
||
const expires =
|
||
duration === null
|
||
? null
|
||
: new Date(Date.parse(now) + (duration as number) * 60_000).toISOString();
|
||
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,case_id)
|
||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||
)
|
||
.run(
|
||
id,
|
||
target.game,
|
||
target.guid,
|
||
input.kind,
|
||
input.scope,
|
||
server?.name ?? null,
|
||
server?.created_at ?? null,
|
||
reason,
|
||
note,
|
||
actor.id,
|
||
actor.username,
|
||
now,
|
||
expires,
|
||
linkedCase?.id ?? null
|
||
);
|
||
advancePolicy();
|
||
moderationEvent(
|
||
actor,
|
||
'restriction.created',
|
||
target,
|
||
id,
|
||
JSON.stringify({
|
||
kind: input.kind,
|
||
scope: input.scope,
|
||
server: server?.name ?? null,
|
||
reason,
|
||
expiresAt: expires,
|
||
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) {
|
||
const id = textField(input.id, 'Restriction ID', 128);
|
||
const row = db().prepare('SELECT * FROM moderation_sanctions WHERE id=?').get(id) as
|
||
SanctionRow | undefined;
|
||
if (!row) throw new AuthError('Restriction not found.', 404);
|
||
if (actor.role !== 'admin' && row.actor_id !== actor.id)
|
||
throw new AuthError(
|
||
'Only an administrator can revoke another moderator’s restriction.',
|
||
403
|
||
);
|
||
if (
|
||
!canRevokeModerationRestriction(actor, {
|
||
actorId: row.actor_id,
|
||
scope: row.scope,
|
||
createdAt: row.created_at,
|
||
expiresAt: row.expires_at
|
||
})
|
||
)
|
||
throw new AuthError(
|
||
'Moderators can only revoke their own server or game restrictions originally lasting up to 7 days.',
|
||
403
|
||
);
|
||
if (row.revoked_at || row.revision !== input.revision)
|
||
throw new AuthError('This restriction changed. Refresh before trying again.', 409);
|
||
const reason = textField(input.reason, 'Revocation reason', 1000);
|
||
db()
|
||
.prepare('UPDATE moderation_sanctions SET revoked_at=?,revision=revision+1 WHERE id=?')
|
||
.run(now, id);
|
||
advancePolicy();
|
||
moderationEvent(actor, 'restriction.revoked', row, id, reason, 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) {
|
||
if (
|
||
typeof input.kind !== 'string' ||
|
||
!['warn', 'message', 'kick', 'announce'].includes(input.kind)
|
||
)
|
||
throw new AuthError('Choose a supported live action.');
|
||
const target = input.kind === 'announce' ? null : subject(input);
|
||
const server = serverTarget(input.server, target?.game);
|
||
const runtime = db()
|
||
.prepare('SELECT * FROM moderation_runtimes WHERE instance_id=? AND generation=?')
|
||
.get(server.instance_id, server.generation) as
|
||
| {
|
||
session: string;
|
||
players: string;
|
||
capabilities: string;
|
||
connected_at: string;
|
||
}
|
||
| undefined;
|
||
if (!runtime || Date.parse(now) - Date.parse(runtime.connected_at) > runtimeFreshMs)
|
||
throw new AuthError(
|
||
'The moderation integration is not connected to this server. Wait for a fresh connection.',
|
||
409
|
||
);
|
||
if (!(JSON.parse(runtime.capabilities) as string[]).includes(String(input.kind)))
|
||
throw new AuthError('This server does not support that action.', 409);
|
||
const matches = (JSON.parse(runtime.players) as { guid: string; connection: string }[]).filter(
|
||
(p) => p.guid === target?.guid
|
||
);
|
||
if (target && matches.length !== 1)
|
||
throw new AuthError(
|
||
'This player’s current connection could not be identified. Refresh the roster.',
|
||
409
|
||
);
|
||
const recent = (
|
||
db()
|
||
.prepare(
|
||
'SELECT COUNT(*) AS count FROM moderation_actions WHERE actor_id=? AND created_at>?'
|
||
)
|
||
.get(actor.id, new Date(Date.parse(now) - 60_000).toISOString()) as { count: number }
|
||
).count;
|
||
if (recent >= 30)
|
||
throw new AuthError('Too many moderation actions. Wait a minute before continuing.', 429);
|
||
const reason = gameText(input.reason, input.kind === 'kick' ? 'Kick reason' : 'Message');
|
||
const id = randomUUID();
|
||
db()
|
||
.prepare(
|
||
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,connection,kind,reason,actor_id,actor,created_at,expires_at,state)
|
||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'queued')`
|
||
)
|
||
.run(
|
||
id,
|
||
server.game,
|
||
target?.guid ?? null,
|
||
server.name,
|
||
server.instance_id,
|
||
server.generation,
|
||
runtime.session,
|
||
target ? matches[0].connection : null,
|
||
input.kind,
|
||
reason,
|
||
actor.id,
|
||
actor.username,
|
||
now,
|
||
new Date(Date.parse(now) + 30_000).toISOString()
|
||
);
|
||
moderationEvent(
|
||
actor,
|
||
'action.queued',
|
||
{ game: server.game, guid: target?.guid },
|
||
id,
|
||
JSON.stringify({ kind: input.kind, server: server.name, reason }),
|
||
now
|
||
);
|
||
return { id };
|
||
}
|
||
function caseRow(value: unknown): CaseRow {
|
||
const row = db()
|
||
.prepare('SELECT * FROM moderation_cases WHERE id=?')
|
||
.get(textField(value, 'Case ID', 128)) as CaseRow | undefined;
|
||
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,
|
||
decision = false
|
||
) {
|
||
if (
|
||
!decision &&
|
||
(
|
||
db()
|
||
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
|
||
.get(id) as { count: number }
|
||
).count >= 100
|
||
)
|
||
throw new AuthError('This case already contains 100 evidence entries.', 409);
|
||
db()
|
||
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
|
||
.run(randomUUID(), id, text, source, snapshot, actor.id, actor.username, now);
|
||
}
|
||
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,priority,assignee_id) VALUES(?,?,?,?,'open',?,?,?,?,?,?)`
|
||
)
|
||
.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);
|
||
const attached =
|
||
input.evidence === undefined ? 0 : 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
|
||
);
|
||
if (note || attached) notifyCase(actor.id, id, 'evidence', now);
|
||
if (assignee.id) notifyCase(actor.id, id, 'assignment', now);
|
||
return { id };
|
||
}
|
||
function archivedEvidence(
|
||
actor: AuthUser,
|
||
id: string,
|
||
target: { game: ModerationGame; guid: string },
|
||
value: unknown,
|
||
now: string
|
||
): number {
|
||
const snapshots = archivedEvidenceSnapshots(id, target, value);
|
||
for (const snapshot of snapshots)
|
||
evidence(actor, id, snapshot.text, snapshot.source, snapshot.snapshot, now);
|
||
return snapshots.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 &&
|
||
!moderationCaseStatuses.includes(status as ModerationCaseStatus)
|
||
)
|
||
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 };
|
||
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
|
||
);
|
||
if (assigneeId !== row.assignee_id)
|
||
notifyCase(actor.id, row.id, 'assignment', now, row.assignee_id);
|
||
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 === 'schedule-case-review') {
|
||
const row = caseRow(input.id);
|
||
checkCaseRevision(row, input.revision);
|
||
const schedule = caseReviewSchedule(row, input, now);
|
||
if (
|
||
row.review_at === schedule.reviewAt &&
|
||
row.review_reason === schedule.reason &&
|
||
row.review_sanction_id === schedule.restrictionId
|
||
)
|
||
return { id: row.id };
|
||
db()
|
||
.prepare(
|
||
`UPDATE moderation_cases SET review_at=?,review_reason=?,review_sanction_id=?,
|
||
updated_at=?,revision=revision+1 WHERE id=?`
|
||
)
|
||
.run(schedule.reviewAt, schedule.reason, schedule.restrictionId, now, row.id);
|
||
moderationEvent(
|
||
actor,
|
||
'case.review_scheduled',
|
||
row,
|
||
row.id,
|
||
JSON.stringify(schedule),
|
||
now
|
||
);
|
||
return { id: row.id };
|
||
}
|
||
if (input.op === 'read-case-updates') {
|
||
const row = caseRow(input.id);
|
||
readCaseUpdates(actor.id, row.id, input.throughId, now);
|
||
return { id: row.id };
|
||
}
|
||
if (input.op === 'link-case' || input.op === 'unlink-case') {
|
||
const row = caseRow(input.id);
|
||
checkCaseRevision(row, input.revision);
|
||
if (input.op === 'link-case')
|
||
linkCases(
|
||
actor,
|
||
row,
|
||
caseRow(input.relatedCaseId),
|
||
input.relation,
|
||
input.reason,
|
||
now
|
||
);
|
||
else unlinkCases(actor, row, input.relationshipId, input.reason, now);
|
||
return { id: row.id };
|
||
}
|
||
if (input.op === 'assess-case') {
|
||
const row = caseRow(input.id);
|
||
checkCaseRevision(row, input.revision);
|
||
const draft = assessmentDraft(input);
|
||
const data = JSON.stringify(draft);
|
||
const prior = db()
|
||
.prepare(
|
||
'SELECT data FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1'
|
||
)
|
||
.get(row.id) as { data: string } | undefined;
|
||
if (prior?.data === data) throw new AuthError('Change the assessment before saving.');
|
||
db()
|
||
.prepare('INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)')
|
||
.run(row.id, row.revision + 1, data, actor.id, actor.username, now);
|
||
touchCase(row.id, now);
|
||
moderationEvent(
|
||
actor,
|
||
'case.assessed',
|
||
row,
|
||
row.id,
|
||
JSON.stringify({ assessmentRevision: row.revision + 1, summary: draft.summary }),
|
||
now
|
||
);
|
||
return { id: row.id };
|
||
}
|
||
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);
|
||
const text = textField(input.text, 'Note', 4000);
|
||
evidence(actor, row.id, text, 'Staff note', null, now);
|
||
touchCase(row.id, now);
|
||
moderationEvent(actor, 'case.note', row, row.id, text, now);
|
||
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))
|
||
throw new AuthError('This case changed. Refresh before resolving it.', 409);
|
||
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('closed', now, row.id);
|
||
moderationEvent(
|
||
actor,
|
||
`case.${outcome}`,
|
||
row,
|
||
row.id,
|
||
reference ? JSON.stringify({ reason, actionReference: reference }) : 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);
|
||
if (!count) return { id: row.id };
|
||
touchCase(row.id, now);
|
||
moderationEvent(
|
||
actor,
|
||
'case.evidence_attached',
|
||
row,
|
||
row.id,
|
||
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
|
||
now
|
||
);
|
||
notifyCase(actor.id, row.id, 'evidence', now);
|
||
return { id: row.id };
|
||
}
|
||
if (input.op === 'note') {
|
||
const target = subject(input);
|
||
const text = textField(input.text, 'Private note', 4000);
|
||
ensureSubject(target.game, target.guid);
|
||
moderationEvent(actor, 'player.note', target, null, text, now);
|
||
return {
|
||
id: String(
|
||
(db().prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id
|
||
)
|
||
};
|
||
}
|
||
throw new AuthError('Unknown moderation action.');
|
||
});
|
||
}
|