Link prior moderation actions to case decisions
This commit is contained in:
@@ -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
|
case also leaves its restrictions in place. Check enforcement coverage after
|
||||||
applying or revoking a restriction.
|
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
|
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
|
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;
|
case for the selected subject. Select at least one message from that subject;
|
||||||
|
|||||||
@@ -125,11 +125,23 @@ export interface ModerationCaseDetail {
|
|||||||
case: ModerationCase;
|
case: ModerationCase;
|
||||||
player: ModerationPlayer;
|
player: ModerationPlayer;
|
||||||
sanctions: ModerationSanction[];
|
sanctions: ModerationSanction[];
|
||||||
|
linkedActions?: ModerationCaseAction[];
|
||||||
|
availableActions?: ModerationCaseAction[];
|
||||||
events: ModerationEvent[];
|
events: ModerationEvent[];
|
||||||
coverage: ModerationCoverage[];
|
coverage: ModerationCoverage[];
|
||||||
assignees: { id: string; username: string }[];
|
assignees: { id: string; username: string }[];
|
||||||
checkedAt: 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 {
|
export interface ModerationEvent {
|
||||||
id: number;
|
id: number;
|
||||||
game: ModerationGame | null;
|
game: ModerationGame | null;
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ import { migrateBank } from './bank.js';
|
|||||||
import { migrateAfk } from './afk.js';
|
import { migrateAfk } from './afk.js';
|
||||||
import { migrateBankCredentials } from './bank-credentials.js';
|
import { migrateBankCredentials } from './bank-credentials.js';
|
||||||
import { migrateTemperatures } from '../hosts/temperatures.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';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
// Append only: never renumber entries or change an applied migration to add an
|
// 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',
|
mode: 'foreign-keys-off',
|
||||||
run: migrateModerationCases
|
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;
|
export const databaseVersion = migrations.length;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { archivedEvidenceSnapshots } from './evidence.js';
|
import { archivedEvidenceSnapshots } from './evidence.js';
|
||||||
|
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
|
||||||
import { AuthError } from '../auth/index.js';
|
import { AuthError } from '../auth/index.js';
|
||||||
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
||||||
import {
|
import {
|
||||||
@@ -220,7 +221,13 @@ function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
|||||||
.run(now, id);
|
.run(now, id);
|
||||||
advancePolicy();
|
advancePolicy();
|
||||||
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
|
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 };
|
return { id };
|
||||||
}
|
}
|
||||||
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
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 === 'action') return liveAction(actor, input, now);
|
||||||
if (input.op === 'create-case') return createCase(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 === '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') {
|
if (input.op === 'case-note') {
|
||||||
const row = caseRow(input.id);
|
const row = caseRow(input.id);
|
||||||
if (input.revision !== undefined) checkCaseRevision(row, input.revision);
|
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);
|
const outcome = moderationCaseOutcomes.find((status) => status === input.status);
|
||||||
if (!outcome) throw new AuthError('Choose a case outcome.');
|
if (!outcome) throw new AuthError('Choose a case outcome.');
|
||||||
const reason = textField(input.reason, 'Decision reason', 4000);
|
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);
|
evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
|
||||||
db()
|
db()
|
||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
||||||
)
|
)
|
||||||
.run(outcome, now, row.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 };
|
return { id: row.id };
|
||||||
}
|
}
|
||||||
if (input.op === 'reopen-case') {
|
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 { AuthError } from '../auth/index.js';
|
||||||
import { moderationActor, textField } from './core.js';
|
import { moderationActor, textField } from './core.js';
|
||||||
import { moderationOverview } from './overview.js';
|
import { moderationOverview } from './overview.js';
|
||||||
|
import { caseActionLists } from './case-actions.js';
|
||||||
import type {
|
import type {
|
||||||
ModerationCase,
|
ModerationCase,
|
||||||
ModerationCaseDetail,
|
ModerationCaseDetail,
|
||||||
@@ -42,9 +43,11 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note,
|
`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
|
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) => ({
|
).map((restriction) => ({
|
||||||
...restriction,
|
...restriction,
|
||||||
active:
|
active:
|
||||||
@@ -55,9 +58,10 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt
|
`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
|
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
|
const assignees = handle
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
|
`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
|
lastSeen: null
|
||||||
},
|
},
|
||||||
sanctions,
|
sanctions,
|
||||||
|
...caseActionLists(caseId, row, overview.checkedAt),
|
||||||
events,
|
events,
|
||||||
coverage: overview.coverage,
|
coverage: overview.coverage,
|
||||||
assignees,
|
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);
|
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);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ModerationCaseAction } from '$lib/moderation.js';
|
||||||
|
import { moderationActionName } from '$ui/moderation.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
|
import Field from '../primitives/Field.svelte';
|
||||||
|
import Select from '../primitives/Select.svelte';
|
||||||
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import StatusBadge from '../primitives/StatusBadge.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
caseId,
|
||||||
|
revision,
|
||||||
|
actions,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
caseId: string;
|
||||||
|
revision: number;
|
||||||
|
actions: ModerationCaseAction[];
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
let selected = $state('');
|
||||||
|
let reason = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let uncertain = $state(false);
|
||||||
|
let pending: Record<string, unknown> | null = null;
|
||||||
|
const action = $derived(actions.find((item) => `${item.type}:${item.id}` === selected));
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
if (!pending) {
|
||||||
|
if (!action || !reason.trim()) {
|
||||||
|
error = 'Select an existing action and explain its connection to this case.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending = {
|
||||||
|
op: 'link-case-action',
|
||||||
|
id: caseId,
|
||||||
|
revision,
|
||||||
|
actionType: action.type,
|
||||||
|
actionId: action.id,
|
||||||
|
reason: reason.trim(),
|
||||||
|
idempotencyKey: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
uncertain = response.status >= 500 || response.ok;
|
||||||
|
if (!uncertain) pending = null;
|
||||||
|
throw new Error(result?.error || 'Could not link the existing action.');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
onsubmitted();
|
||||||
|
} catch (cause) {
|
||||||
|
if (pending) uncertain = true;
|
||||||
|
error = cause instanceof Error ? cause.message : 'Could not link the existing action.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
bind:open
|
||||||
|
title="Link existing action"
|
||||||
|
description="Connect a previously recorded action to this case. Linking preserves its original date and outcome without issuing it again."
|
||||||
|
{busy}
|
||||||
|
>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<fieldset disabled={busy || uncertain}>
|
||||||
|
<Field label="Existing action" required>
|
||||||
|
<Select
|
||||||
|
bind:value={selected}
|
||||||
|
required
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Select an action' },
|
||||||
|
...actions.map((item) => ({
|
||||||
|
value: `${item.type}:${item.id}`,
|
||||||
|
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{#if action}<div class="action-detail">
|
||||||
|
<strong>{moderationActionName(action.kind)}</strong>
|
||||||
|
<StatusBadge state={`moderation-${action.state}`} />
|
||||||
|
<p>{action.reason}</p>
|
||||||
|
<p class="hint">
|
||||||
|
Recorded by {action.actor} · {$timeFormat.dateTime(action.createdAt)}
|
||||||
|
</p>
|
||||||
|
{#if action.type === 'restriction'}<p class="hint">
|
||||||
|
{action.expiresAt
|
||||||
|
? `Expires ${$timeFormat.dateTime(action.expiresAt)}`
|
||||||
|
: 'Permanent restriction'}
|
||||||
|
</p>{/if}
|
||||||
|
</div>{/if}
|
||||||
|
<Field label="Link reason" required hint="Explain how this action relates to the case."
|
||||||
|
><Textarea bind:value={reason} rows={3} maxlength={4000} required /></Field
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if uncertain}<p class="hint">
|
||||||
|
The result is uncertain. Retry sends the same request safely.
|
||||||
|
</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={busy}>{uncertain ? 'Retry same request' : 'Link action'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.action-detail {
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.action-detail p {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,9 +7,11 @@
|
|||||||
type ModerationCoverage,
|
type ModerationCoverage,
|
||||||
type ModerationGame,
|
type ModerationGame,
|
||||||
type ModerationScope,
|
type ModerationScope,
|
||||||
type ModerationCase
|
type ModerationCase,
|
||||||
|
type ModerationCaseAction
|
||||||
} from '$lib/moderation.js';
|
} from '$lib/moderation.js';
|
||||||
import { moderationGameName } from '$ui/moderation.js';
|
import { moderationActionName, moderationGameName } from '$ui/moderation.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import Dialog from '../primitives/Dialog.svelte';
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
admin,
|
admin,
|
||||||
seed = {},
|
seed = {},
|
||||||
assignees = [],
|
assignees = [],
|
||||||
|
linkedActions = [],
|
||||||
onsubmitted
|
onsubmitted
|
||||||
}: {
|
}: {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
coverage: ModerationCoverage[];
|
coverage: ModerationCoverage[];
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
assignees?: { id: string; username: string }[];
|
assignees?: { id: string; username: string }[];
|
||||||
|
linkedActions?: ModerationCaseAction[];
|
||||||
seed?: {
|
seed?: {
|
||||||
game?: ModerationGame;
|
game?: ModerationGame;
|
||||||
guid?: string;
|
guid?: string;
|
||||||
@@ -81,6 +85,21 @@
|
|||||||
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
||||||
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
||||||
let resolution = $state('resolved');
|
let resolution = $state('resolved');
|
||||||
|
let actionReference = $state('');
|
||||||
|
const referenceActions = $derived(
|
||||||
|
linkedActions.filter((item) =>
|
||||||
|
resolution === 'warning_issued'
|
||||||
|
? item.type === 'action' && item.kind === 'warn' && item.state === 'confirmed'
|
||||||
|
: resolution === 'restriction_applied' && item.type === 'restriction'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const referencedAction = $derived(
|
||||||
|
referenceActions.find((item) => `${item.type}:${item.id}` === actionReference)
|
||||||
|
);
|
||||||
|
$effect(() => {
|
||||||
|
void resolution;
|
||||||
|
actionReference = '';
|
||||||
|
});
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let uncertain = $state(false);
|
let uncertain = $state(false);
|
||||||
@@ -203,7 +222,10 @@
|
|||||||
id: seed.id,
|
id: seed.id,
|
||||||
revision: seed.revision,
|
revision: seed.revision,
|
||||||
status: resolution,
|
status: resolution,
|
||||||
reason: reason.trim()
|
reason: reason.trim(),
|
||||||
|
...(referencedAction
|
||||||
|
? { actionReference: { type: referencedAction.type, id: referencedAction.id } }
|
||||||
|
: {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +490,29 @@
|
|||||||
separately, and send warnings through Live action. Choosing an outcome does not
|
separately, and send warnings through Live action. Choosing an outcome does not
|
||||||
change enforcement or send a warning.
|
change enforcement or send a warning.
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
|
{#if mode === 'resolve' && ['warning_issued', 'restriction_applied'].includes(resolution)}
|
||||||
|
<Field
|
||||||
|
label="Supporting action"
|
||||||
|
hint="Optional. Reference an existing linked action as the basis for this outcome."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
bind:value={actionReference}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'No action reference' },
|
||||||
|
...referenceActions.map((item) => ({
|
||||||
|
value: `${item.type}:${item.id}`,
|
||||||
|
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{#if !referenceActions.length}<p class="hint">
|
||||||
|
{resolution === 'warning_issued'
|
||||||
|
? 'No confirmed warnings are linked to this case.'
|
||||||
|
: 'No restrictions are linked to this case.'} Use “Link existing action” on
|
||||||
|
the case to add a prior action.
|
||||||
|
</p>{/if}
|
||||||
|
{/if}
|
||||||
{#if mode === 'reopen'}<p class="hint">
|
{#if mode === 'reopen'}<p class="hint">
|
||||||
Reopening returns the case to Open and preserves its evidence, restrictions, and
|
Reopening returns the case to Open and preserves its evidence, restrictions, and
|
||||||
earlier decisions.
|
earlier decisions.
|
||||||
|
|||||||
@@ -70,10 +70,18 @@ export const moderationActionLabels: Record<string, string> = {
|
|||||||
'case.updated': 'Case updated',
|
'case.updated': 'Case updated',
|
||||||
'case.evidence_attached': 'Evidence attached',
|
'case.evidence_attached': 'Evidence attached',
|
||||||
'case.restriction': 'Restriction linked to case',
|
'case.restriction': 'Restriction linked to case',
|
||||||
|
'case.action_linked': 'Existing action linked to case',
|
||||||
|
'link-case-action': 'Existing action linked to case',
|
||||||
'player.note': 'Private player note',
|
'player.note': 'Private player note',
|
||||||
'runtime.configure': 'Integration configuration requested'
|
'runtime.configure': 'Integration configuration requested'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function moderationActionName(kind: string): string {
|
||||||
|
return (
|
||||||
|
moderationActionLabels[kind] ?? (kind === 'ban' ? 'Ban' : kind === 'mute' ? 'Mute' : kind)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function moderationEventDetail(detail: string, action: string): string {
|
export function moderationEventDetail(detail: string, action: string): string {
|
||||||
if (action.startsWith('case.')) {
|
if (action.startsWith('case.')) {
|
||||||
try {
|
try {
|
||||||
@@ -110,6 +118,18 @@ export function moderationEventDetail(detail: string, action: string): string {
|
|||||||
return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`;
|
return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`;
|
||||||
if (action === 'case.restriction' && typeof value.reason === 'string')
|
if (action === 'case.restriction' && typeof value.reason === 'string')
|
||||||
return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`;
|
return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`;
|
||||||
|
if (
|
||||||
|
typeof value.reason === 'string' &&
|
||||||
|
value.actionReference &&
|
||||||
|
typeof value.actionReference === 'object'
|
||||||
|
) {
|
||||||
|
const reference = value.actionReference as { type?: unknown; id?: unknown };
|
||||||
|
if (
|
||||||
|
(reference.type === 'restriction' || reference.type === 'action') &&
|
||||||
|
typeof reference.id === 'string'
|
||||||
|
)
|
||||||
|
return `${value.reason}\nReferenced ${reference.type === 'restriction' ? 'restriction' : 'action'}: ${reference.id}`;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Older case events and decision reasons are plain staff-authored text.
|
// Older case events and decision reasons are plain staff-authored text.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import {
|
import {
|
||||||
moderationActionLabels,
|
moderationActionLabels,
|
||||||
|
moderationActionName,
|
||||||
moderationEventDetail,
|
moderationEventDetail,
|
||||||
moderationGameName,
|
moderationGameName,
|
||||||
moderationHref
|
moderationHref
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||||
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
|
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
|
||||||
import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte';
|
import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte';
|
||||||
|
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
|
||||||
import { preservedConversation } from '$ui/moderation-evidence.js';
|
import { preservedConversation } from '$ui/moderation-evidence.js';
|
||||||
|
|
||||||
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
|
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
let generation = 0;
|
let generation = 0;
|
||||||
let controller: AbortController | undefined;
|
let controller: AbortController | undefined;
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
|
let linkActionOpen = $state(false);
|
||||||
let dialogKey = $state(0);
|
let dialogKey = $state(0);
|
||||||
let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note');
|
let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note');
|
||||||
let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({});
|
let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({});
|
||||||
@@ -67,6 +70,7 @@
|
|||||||
refreshError = '';
|
refreshError = '';
|
||||||
notice = '';
|
notice = '';
|
||||||
dialogOpen = false;
|
dialogOpen = false;
|
||||||
|
linkActionOpen = false;
|
||||||
updatedAt = Date.parse(initial.checkedAt);
|
updatedAt = Date.parse(initial.checkedAt);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -149,6 +153,12 @@
|
|||||||
cancelRefresh();
|
cancelRefresh();
|
||||||
void refresh();
|
void refresh();
|
||||||
}
|
}
|
||||||
|
function actionLinked() {
|
||||||
|
notice =
|
||||||
|
'Existing action linked to this case. Its original date and outcome are preserved.';
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
|
||||||
function archiveContext(raw: string | null | undefined) {
|
function archiveContext(raw: string | null | undefined) {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -218,8 +228,8 @@
|
|||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
|
||||||
{#if !permitted}<p class="notice">
|
{#if !permitted}<p class="notice">
|
||||||
Read-only moderation access. You can inspect this case, its evidence, and linked
|
Read-only moderation access. You can inspect this case, its evidence, and linked actions
|
||||||
restrictions.
|
and restrictions.
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||||
{#if refreshError}<div class="ui-error" role="alert">
|
{#if refreshError}<div class="ui-error" role="alert">
|
||||||
@@ -400,6 +410,45 @@
|
|||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="actions-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="actions-title">Linked actions</h2>
|
||||||
|
{#if permitted}<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!report.availableActions?.length}
|
||||||
|
onclick={() => (linkActionOpen = true)}>Link existing action</Button
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if report.linkedActions?.length}<ol class="records">
|
||||||
|
{#each report.linkedActions as action (`${action.type}:${action.id}`)}<li
|
||||||
|
class="record"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<strong>{moderationActionName(action.kind)}</strong><StatusBadge
|
||||||
|
state={`moderation-${action.state}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="record-text">{action.reason}</p>
|
||||||
|
<p class="hint">
|
||||||
|
{action.actor} · <RelativeTime value={action.createdAt} />
|
||||||
|
</p>
|
||||||
|
<CopyValue
|
||||||
|
value={action.id}
|
||||||
|
label={action.type === 'restriction'
|
||||||
|
? 'Restriction ID'
|
||||||
|
: 'Action ID'}
|
||||||
|
/>
|
||||||
|
</li>{/each}
|
||||||
|
</ol>{:else}<p class="hint">
|
||||||
|
No existing actions are linked to this case.
|
||||||
|
</p>{/if}
|
||||||
|
{#if permitted}<p class="hint">
|
||||||
|
{report.availableActions?.length
|
||||||
|
? `${report.availableActions.length} previous actions available for this player.`
|
||||||
|
: 'No additional recorded actions are available for this player.'}
|
||||||
|
</p>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel" aria-labelledby="restrictions-title">
|
<section class="panel" aria-labelledby="restrictions-title">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="restrictions-title">Linked restrictions</h2>
|
<h2 id="restrictions-title">Linked restrictions</h2>
|
||||||
@@ -466,9 +515,7 @@
|
|||||||
})}>Revoke restriction</Button
|
})}>Revoke restriction</Button
|
||||||
>{/if}
|
>{/if}
|
||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ol>{:else}<p class="hint">
|
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
|
||||||
No restrictions have been applied from this case.
|
|
||||||
</p>{/if}
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Applying or revoking a restriction does not change the case outcome.
|
Applying or revoking a restriction does not change the case outcome.
|
||||||
</p>
|
</p>
|
||||||
@@ -506,9 +553,18 @@
|
|||||||
{admin}
|
{admin}
|
||||||
coverage={report.coverage}
|
coverage={report.coverage}
|
||||||
assignees={report.assignees}
|
assignees={report.assignees}
|
||||||
|
linkedActions={report.linkedActions}
|
||||||
onsubmitted={submitted}
|
onsubmitted={submitted}
|
||||||
/>{/key}{/if}
|
/>{/key}{/if}
|
||||||
|
|
||||||
|
{#if permitted && linkActionOpen}<LinkCaseActionDialog
|
||||||
|
bind:open={linkActionOpen}
|
||||||
|
caseId={item.id}
|
||||||
|
revision={item.revision}
|
||||||
|
actions={report.availableActions ?? []}
|
||||||
|
onsubmitted={actionLinked}
|
||||||
|
/>{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.case-page,
|
.case-page,
|
||||||
.case-main,
|
.case-main,
|
||||||
|
|||||||
@@ -216,7 +216,8 @@ it('preserves original evidence context, linked restrictions, and staff history
|
|||||||
'Restrict player',
|
'Restrict player',
|
||||||
'Revoke restriction',
|
'Revoke restriction',
|
||||||
'Add case note',
|
'Add case note',
|
||||||
'Attach evidence'
|
'Attach evidence',
|
||||||
|
'Link existing action'
|
||||||
])
|
])
|
||||||
expect(button(label)).toBeUndefined();
|
expect(button(label)).toBeUndefined();
|
||||||
expect(document.querySelector('dialog')).toBeNull();
|
expect(document.querySelector('dialog')).toBeNull();
|
||||||
@@ -380,6 +381,122 @@ it('attaches archived evidence by match and exact message with revision checks',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links a prior mute with its recorded outcome without issuing another restriction', async () => {
|
||||||
|
const initial = detail();
|
||||||
|
const prior = {
|
||||||
|
id: 'earlier-mute',
|
||||||
|
type: 'restriction' as const,
|
||||||
|
kind: 'mute',
|
||||||
|
reason: 'Already applied before the report',
|
||||||
|
actor: 'Moderator Two',
|
||||||
|
createdAt: '2026-10-14T10:00:00Z',
|
||||||
|
state: 'expired',
|
||||||
|
expiresAt: '2026-10-14T11:00:00Z'
|
||||||
|
};
|
||||||
|
initial.availableActions = [prior];
|
||||||
|
initial.linkedActions = [];
|
||||||
|
const updated = detail();
|
||||||
|
updated.linkedActions = [prior];
|
||||||
|
updated.availableActions = [];
|
||||||
|
updated.case.revision = 5;
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValue(response(updated));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show(initial);
|
||||||
|
button('Link existing action').click();
|
||||||
|
await tick();
|
||||||
|
select('Existing action', 'restriction:earlier-mute');
|
||||||
|
expect(document.querySelector('dialog[open]')?.textContent).toContain('Expired');
|
||||||
|
expect(document.querySelector('dialog[open]')?.textContent).toContain('Moderator Two');
|
||||||
|
enter(field('Link reason'), 'This mute addressed the reported chat');
|
||||||
|
button('Link action').click();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
op: 'link-case-action',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
actionType: 'restriction',
|
||||||
|
actionId: 'earlier-mute',
|
||||||
|
reason: 'This mute addressed the reported chat',
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
|
||||||
|
'Already applied before the report'
|
||||||
|
);
|
||||||
|
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
|
||||||
|
'Expired'
|
||||||
|
);
|
||||||
|
expect(button('Link existing action').disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['warning_issued', 'action:warning-1', 'action', 'warning-1'],
|
||||||
|
['restriction_applied', 'restriction:restriction-1', 'restriction', 'restriction-1']
|
||||||
|
] as const)(
|
||||||
|
'references a linked action when recording %s',
|
||||||
|
async (outcome, selection, type, id) => {
|
||||||
|
const initial = detail();
|
||||||
|
initial.linkedActions = [
|
||||||
|
{
|
||||||
|
id: 'warning-1',
|
||||||
|
type: 'action',
|
||||||
|
kind: 'warn',
|
||||||
|
reason: 'Confirmed warning',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-15T10:00:00Z',
|
||||||
|
state: 'confirmed',
|
||||||
|
expiresAt: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed-warning',
|
||||||
|
type: 'action',
|
||||||
|
kind: 'warn',
|
||||||
|
reason: 'Undelivered warning',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-15T09:00:00Z',
|
||||||
|
state: 'failed',
|
||||||
|
expiresAt: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'restriction-1',
|
||||||
|
type: 'restriction',
|
||||||
|
kind: 'mute',
|
||||||
|
reason: 'Prior mute',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-15T11:00:00Z',
|
||||||
|
state: 'expired',
|
||||||
|
expiresAt: '2026-10-15T12:00:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
|
.mockResolvedValue(response(initial));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
show(initial);
|
||||||
|
button('Review case').click();
|
||||||
|
await tick();
|
||||||
|
select('Outcome', outcome);
|
||||||
|
const options = [...field<HTMLSelectElement>('Supporting action').options].map(
|
||||||
|
(option) => option.value
|
||||||
|
);
|
||||||
|
expect(options).toEqual(['', selection]);
|
||||||
|
select('Supporting action', selection);
|
||||||
|
enter(field('Review reason'), 'Reviewed the recorded action');
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 4,
|
||||||
|
status: outcome,
|
||||||
|
actionReference: { type, id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
|
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
|
||||||
const updated = detail();
|
const updated = detail();
|
||||||
updated.case.revision = 8;
|
updated.case.revision = 8;
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
const clock = '2026-10-17T12:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date(clock));
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
handle = db();
|
||||||
|
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(id: string, actor = 'moderator') {
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
return moderationCaseDetail(actor, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct', ...overrides });
|
||||||
|
|
||||||
|
const restriction = (overrides: Record<string, unknown> = {}, actor = 'moderator') =>
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
op: 'create-sanction',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
kind: 'mute',
|
||||||
|
scope: 'game',
|
||||||
|
durationMinutes: 60,
|
||||||
|
reason: 'Earlier moderation action',
|
||||||
|
...overrides
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
);
|
||||||
|
|
||||||
|
function action(overrides: Record<string, string | null> = {}) {
|
||||||
|
const record = {
|
||||||
|
id: randomUUID(),
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
kind: 'warn',
|
||||||
|
state: 'confirmed',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,
|
||||||
|
kind,reason,actor_id,actor,created_at,expires_at,state)
|
||||||
|
VALUES(?,?,?,'retired-server','retired-instance',1,'old-session',?,
|
||||||
|
'Earlier warning','moderator','Former moderator',?,?,?)`
|
||||||
|
)
|
||||||
|
.run(record.id, record.game, record.guid, record.kind, clock, clock, record.state);
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||||
|
VALUES(?,?,'Former moderator',?,?,'Recorded delivery result',?)`
|
||||||
|
)
|
||||||
|
.run(record.game, record.guid, `action.${record.state}`, record.id, clock);
|
||||||
|
return record.id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforcement() {
|
||||||
|
return {
|
||||||
|
sanctions: handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all(),
|
||||||
|
actions: handle.prepare('SELECT * FROM moderation_actions ORDER BY id').all(),
|
||||||
|
policy: handle.prepare('SELECT * FROM moderation_policy').all()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = (id: string, revision: number, actionType: string, actionId: string) => ({
|
||||||
|
op: 'link-case-action',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
actionType,
|
||||||
|
actionId,
|
||||||
|
reason: 'This earlier action addresses the incident under review.'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links a prior restriction to multiple cases without changing enforcement or its original case', async () => {
|
||||||
|
const original = await create();
|
||||||
|
const sanction = await restriction({ caseId: original.id, caseRevision: 1 });
|
||||||
|
const current = await create();
|
||||||
|
const before = enforcement();
|
||||||
|
const request = {
|
||||||
|
...link(current.id, 1, 'restriction', sanction.id),
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
};
|
||||||
|
await mutate(request);
|
||||||
|
await mutate(request);
|
||||||
|
let data = await detail(current.id);
|
||||||
|
expect(data.case.revision).toBe(2);
|
||||||
|
expect(data.sanctions).toMatchObject([{ id: sanction.id, caseId: original.id }]);
|
||||||
|
expect(data.linkedActions).toMatchObject([
|
||||||
|
{ id: sanction.id, type: 'restriction', state: 'active' }
|
||||||
|
]);
|
||||||
|
expect(data.availableActions).toEqual([]);
|
||||||
|
expect(data.events.map((event) => event.action)).toContain('restriction.created');
|
||||||
|
expect((await detail(original.id)).linkedActions).toMatchObject([{ id: sanction.id }]);
|
||||||
|
expect(enforcement()).toEqual(before);
|
||||||
|
await mutate(link(current.id, 2, 'restriction', sanction.id));
|
||||||
|
expect(await detail(current.id)).toEqual(data);
|
||||||
|
expect(
|
||||||
|
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_action_links').get()
|
||||||
|
).toEqual({ count: 1 });
|
||||||
|
const event = data.events.find((entry) => entry.action === 'case.action_linked')!;
|
||||||
|
expect(JSON.parse(event.detail)).toEqual({
|
||||||
|
reason: request.reason,
|
||||||
|
actionReference: { type: 'restriction', id: sanction.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
await mutate({
|
||||||
|
op: 'revoke-sanction',
|
||||||
|
id: sanction.id,
|
||||||
|
revision: 1,
|
||||||
|
reason: 'Review completed'
|
||||||
|
});
|
||||||
|
data = await detail(current.id);
|
||||||
|
expect(data.case.revision).toBe(3);
|
||||||
|
expect((await detail(original.id)).case.revision).toBe(3);
|
||||||
|
expect(data.linkedActions?.[0].state).toBe('revoked');
|
||||||
|
expect(data.events[0].action).toBe('restriction.revoked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links historical live actions to closed cases and exposes their real delivery state and timeline', async () => {
|
||||||
|
const warningId = action();
|
||||||
|
const failedId = action({ state: 'failed' });
|
||||||
|
action({ game: 't6' });
|
||||||
|
action({ guid: '456' });
|
||||||
|
action({ guid: null, kind: 'announce' });
|
||||||
|
const { id } = await create();
|
||||||
|
expect((await detail(id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
|
||||||
|
[warningId, failedId].sort()
|
||||||
|
);
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
status: 'no_action',
|
||||||
|
reason: 'Initial review complete'
|
||||||
|
});
|
||||||
|
const before = enforcement();
|
||||||
|
await mutate(link(id, 2, 'action', warningId));
|
||||||
|
const data = await detail(id, 'super_viewer');
|
||||||
|
expect(data.case).toMatchObject({ status: 'no_action', revision: 3 });
|
||||||
|
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
|
||||||
|
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
|
||||||
|
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
|
||||||
|
expect(enforcement()).toEqual(before);
|
||||||
|
await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthorized, stale, missing and wrong-player or game links and permits a matching fleet restriction', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const right = action();
|
||||||
|
const otherPlayer = action({ guid: '456' });
|
||||||
|
const otherGame = action({ game: 't6' });
|
||||||
|
const wrongRestriction = await restriction({ guid: '456' });
|
||||||
|
for (const actor of ['super_viewer', 'viewer'])
|
||||||
|
await expect(mutate(link(id, 1, 'action', right), actor)).rejects.toMatchObject({
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
await expect(mutate(link(id, 2, 'action', right))).rejects.toMatchObject({ status: 409 });
|
||||||
|
for (const [type, source] of [
|
||||||
|
['action', otherPlayer],
|
||||||
|
['action', otherGame],
|
||||||
|
['action', 'missing'],
|
||||||
|
['restriction', wrongRestriction.id]
|
||||||
|
])
|
||||||
|
await expect(mutate(link(id, 1, type, source))).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect((await detail(id)).case.revision).toBe(1);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
|
||||||
|
|
||||||
|
for (const game of ['t5', 't6'])
|
||||||
|
handle
|
||||||
|
.prepare('INSERT INTO moderation_aliases VALUES(?,?,?,?,?)')
|
||||||
|
.run(game, '123', 'Player', clock, clock);
|
||||||
|
const fleet = await restriction({ scope: 'fleet' }, 'admin');
|
||||||
|
const t6 = await create({ game: 't6' });
|
||||||
|
expect((await detail(t6.id)).availableActions?.map((entry) => entry.id).sort()).toEqual(
|
||||||
|
[fleet.id, otherGame].sort()
|
||||||
|
);
|
||||||
|
await mutate(link(t6.id, 1, 'restriction', fleet.id));
|
||||||
|
expect((await detail(t6.id)).sanctions).toMatchObject([
|
||||||
|
{ id: fleet.id, scope: 'fleet', game: 't5' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a linked confirmed warning for a cited warning outcome and preserves the actual reference', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const confirmed = action();
|
||||||
|
const failed = action({ state: 'failed' });
|
||||||
|
const kick = action({ kind: 'kick' });
|
||||||
|
const sanction = await restriction();
|
||||||
|
const resolve = (
|
||||||
|
revision: number,
|
||||||
|
type: string,
|
||||||
|
actionId: string,
|
||||||
|
status = 'warning_issued'
|
||||||
|
) => ({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
status,
|
||||||
|
reason: 'Decision after evidence review',
|
||||||
|
actionReference: { type, id: actionId }
|
||||||
|
});
|
||||||
|
await expect(mutate(resolve(1, 'action', confirmed))).rejects.toThrow(
|
||||||
|
'Link the recorded action'
|
||||||
|
);
|
||||||
|
await mutate(link(id, 1, 'action', failed));
|
||||||
|
await mutate(link(id, 2, 'action', kick));
|
||||||
|
await mutate(link(id, 3, 'restriction', sanction.id));
|
||||||
|
for (const [type, source] of [
|
||||||
|
['action', failed],
|
||||||
|
['action', kick],
|
||||||
|
['restriction', sanction.id]
|
||||||
|
])
|
||||||
|
await expect(mutate(resolve(4, type, source))).rejects.toThrow('confirmed warning');
|
||||||
|
await mutate(link(id, 4, 'action', confirmed));
|
||||||
|
await expect(mutate(resolve(5, 'action', confirmed, 'restriction_applied'))).rejects.toThrow(
|
||||||
|
'recorded restriction'
|
||||||
|
);
|
||||||
|
const before = enforcement();
|
||||||
|
await mutate(resolve(5, 'action', confirmed));
|
||||||
|
const data = await detail(id);
|
||||||
|
expect(data.case).toMatchObject({ status: 'warning_issued', revision: 6 });
|
||||||
|
expect(JSON.parse(data.events[0].detail)).toEqual({
|
||||||
|
reason: 'Decision after evidence review',
|
||||||
|
actionReference: { type: 'action', id: confirmed }
|
||||||
|
});
|
||||||
|
expect(enforcement()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a historical expired restriction created from the original case to support its closure', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const sanction = await restriction({ caseId: id, caseRevision: 1 });
|
||||||
|
vi.setSystemTime(new Date(Date.parse(clock) + 120 * 60_000));
|
||||||
|
expect((await detail(id)).linkedActions).toMatchObject([{ id: sanction.id, state: 'expired' }]);
|
||||||
|
const before = enforcement();
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision: 2,
|
||||||
|
status: 'restriction_applied',
|
||||||
|
reason: 'Earlier mute addressed the incident',
|
||||||
|
actionReference: { type: 'restriction', id: sanction.id }
|
||||||
|
});
|
||||||
|
expect((await detail(id)).case.status).toBe('restriction_applied');
|
||||||
|
expect(enforcement()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back a link, case revision and idempotency record if its audit write fails', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const warning = action();
|
||||||
|
const before = await detail(id);
|
||||||
|
handle.exec(`CREATE TRIGGER reject_case_action_audit BEFORE INSERT ON moderation_events
|
||||||
|
WHEN NEW.action='case.action_linked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
|
||||||
|
const request = { ...link(id, 1, 'action', warning), idempotencyKey: randomUUID() };
|
||||||
|
await expect(mutate(request)).rejects.toThrow('audit unavailable');
|
||||||
|
expect(await detail(id)).toEqual(before);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_case_action_links').all()).toEqual([]);
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
|
||||||
|
.all(request.idempotencyKey)
|
||||||
|
).toEqual([]);
|
||||||
|
handle.exec('DROP TRIGGER reject_case_action_audit');
|
||||||
|
await mutate(request);
|
||||||
|
expect((await detail(id)).linkedActions).toMatchObject([{ id: warning }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades existing databases without changing original case restrictions and enforces link identity', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const legacy = new Database(':memory:');
|
||||||
|
legacy.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
runMigrations(legacy, migrations.slice(0, 30));
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
|
||||||
|
VALUES('case','t5','123','Prior case','open','former','Former staff',?,?)`
|
||||||
|
)
|
||||||
|
.run(clock, clock);
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
|
||||||
|
VALUES('restriction','t5','123','mute','game','Prior restriction','','former','Former staff',?,'case')`
|
||||||
|
)
|
||||||
|
.run(clock);
|
||||||
|
const before = legacy.prepare('SELECT * FROM moderation_sanctions').all();
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
expect(legacy.prepare('SELECT * FROM moderation_sanctions').all()).toEqual(before);
|
||||||
|
const insert = legacy.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)');
|
||||||
|
insert.run('case', 'restriction', 'restriction', 'Moderator', clock);
|
||||||
|
expect(() => insert.run('case', 'restriction', 'restriction', 'Moderator', clock)).toThrow(
|
||||||
|
'UNIQUE'
|
||||||
|
);
|
||||||
|
expect(() => insert.run('missing', 'action', 'action', 'Moderator', clock)).toThrow(
|
||||||
|
'FOREIGN KEY'
|
||||||
|
);
|
||||||
|
expect(() => insert.run('case', 'other', 'action', 'Moderator', clock)).toThrow('CHECK');
|
||||||
|
expect(legacy.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user