diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md index efe2d5b..9191bb0 100644 --- a/docs/guides/moderation.md +++ b/docs/guides/moderation.md @@ -149,6 +149,15 @@ 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. +Use **Link existing action** to associate a warning, kick, private message, ban, or +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. + 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 case for the selected subject. Select at least one message from that subject; diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 652d5fd..da70f49 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -125,11 +125,23 @@ export interface ModerationCaseDetail { case: ModerationCase; player: ModerationPlayer; sanctions: ModerationSanction[]; + linkedActions?: ModerationCaseAction[]; + availableActions?: ModerationCaseAction[]; events: ModerationEvent[]; coverage: ModerationCoverage[]; assignees: { id: string; username: string }[]; checkedAt: string; } +export interface ModerationCaseAction { + id: string; + type: 'restriction' | 'action'; + kind: string; + reason: string; + actor: string; + createdAt: string; + state: string; + expiresAt: string | null; +} export interface ModerationEvent { id: number; game: ModerationGame | null; diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index ac519d4..2abffa2 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -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; diff --git a/src/lib/server/moderation/actions.ts b/src/lib/server/moderation/actions.ts index 302a7d4..8f25a7d 100644 --- a/src/lib/server/moderation/actions.ts +++ b/src/lib/server/moderation/actions.ts @@ -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, 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, now: string) { @@ -465,6 +472,27 @@ export function mutateModeration(actorId: string, input: Record 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 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') { diff --git a/src/lib/server/moderation/case-actions.ts b/src/lib/server/moderation/case-actions.ts new file mode 100644 index 0000000..7b0eb38 --- /dev/null +++ b/src/lib/server/moderation/case-actions.ts @@ -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; + +export function caseActionReference(value: unknown): CaseActionReference { + if (!value || typeof value !== 'object') throw new AuthError('Choose a recorded action.'); + const reference = value as Record; + 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; + const linkedActions: ModerationCaseAction[] = []; + const availableActions: ModerationCaseAction[] = []; + for (const { linked, ...record } of records) + (linked ? linkedActions : availableActions).push(record); + return { linkedActions, availableActions }; +} diff --git a/src/lib/server/moderation/cases.ts b/src/lib/server/moderation/cases.ts index f8b39f5..4841f42 100644 --- a/src/lib/server/moderation/cases.ts +++ b/src/lib/server/moderation/cases.ts @@ -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[] + .all(caseId, caseId) as Omit[] ).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, diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts index 9259434..d753171 100644 --- a/src/lib/server/moderation/schema.ts +++ b/src/lib/server/moderation/schema.ts @@ -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); + `); +} diff --git a/src/lib/ui/components/moderation/LinkCaseActionDialog.svelte b/src/lib/ui/components/moderation/LinkCaseActionDialog.svelte new file mode 100644 index 0000000..791845b --- /dev/null +++ b/src/lib/ui/components/moderation/LinkCaseActionDialog.svelte @@ -0,0 +1,161 @@ + + + +
+
+ +