Link prior moderation actions to case decisions
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -28,7 +28,11 @@ import { migrateBank } from './bank.js';
|
||||
import { migrateAfk } from './afk.js';
|
||||
import { migrateBankCredentials } from './bank-credentials.js';
|
||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||
import { migrateModeration, migrateModerationCases } from '../moderation/schema.js';
|
||||
import {
|
||||
migrateModeration,
|
||||
migrateModerationCases,
|
||||
migrateModerationCaseActions
|
||||
} from '../moderation/schema.js';
|
||||
import type { Migration } from './migration-runner.js';
|
||||
|
||||
// Append only: never renumber entries or change an applied migration to add an
|
||||
@@ -170,7 +174,12 @@ export const migrations: readonly Migration[] = [
|
||||
mode: 'foreign-keys-off',
|
||||
run: migrateModerationCases
|
||||
},
|
||||
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk }
|
||||
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk },
|
||||
{
|
||||
version: 31,
|
||||
name: 'moderation case links to existing actions',
|
||||
run: migrateModerationCaseActions
|
||||
}
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { archivedEvidenceSnapshots } from './evidence.js';
|
||||
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
||||
import {
|
||||
@@ -220,7 +221,13 @@ function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: st
|
||||
.run(now, id);
|
||||
advancePolicy();
|
||||
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
|
||||
if (row.case_id) touchCase(row.case_id, now);
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE moderation_cases SET updated_at=?,revision=revision+1
|
||||
WHERE id=? OR id IN (SELECT case_id FROM moderation_case_action_links
|
||||
WHERE action_type='restriction' AND action_id=?)`
|
||||
)
|
||||
.run(now, row.case_id, id);
|
||||
return { id };
|
||||
}
|
||||
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
@@ -465,6 +472,27 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
||||
if (input.op === 'action') return liveAction(actor, input, now);
|
||||
if (input.op === 'create-case') return createCase(actor, input, now);
|
||||
if (input.op === 'update-case') return updateCase(actor, input, now);
|
||||
if (input.op === 'link-case-action') {
|
||||
const row = caseRow(input.id);
|
||||
checkCaseRevision(row, input.revision);
|
||||
const reference = caseActionReference({ type: input.actionType, id: input.actionId });
|
||||
const reason = textField(input.reason, 'Link reason', 4000);
|
||||
recordedCaseAction(row, reference, now);
|
||||
if (isCaseActionLinked(row.id, reference)) return { id: row.id };
|
||||
db()
|
||||
.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)')
|
||||
.run(row.id, reference.type, reference.id, actor.username, now);
|
||||
touchCase(row.id, now);
|
||||
moderationEvent(
|
||||
actor,
|
||||
'case.action_linked',
|
||||
row,
|
||||
row.id,
|
||||
JSON.stringify({ actionReference: reference, reason }),
|
||||
now
|
||||
);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'case-note') {
|
||||
const row = caseRow(input.id);
|
||||
if (input.revision !== undefined) checkCaseRevision(row, input.revision);
|
||||
@@ -481,13 +509,43 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
||||
const outcome = moderationCaseOutcomes.find((status) => status === input.status);
|
||||
if (!outcome) throw new AuthError('Choose a case outcome.');
|
||||
const reason = textField(input.reason, 'Decision reason', 4000);
|
||||
const reference =
|
||||
input.actionReference === undefined || input.actionReference === null
|
||||
? null
|
||||
: caseActionReference(input.actionReference);
|
||||
if (reference) {
|
||||
const action = recordedCaseAction(row, reference, now);
|
||||
if (!isCaseActionLinked(row.id, reference))
|
||||
throw new AuthError(
|
||||
'Link the recorded action to this case before citing it.',
|
||||
409
|
||||
);
|
||||
if (
|
||||
outcome === 'warning_issued' &&
|
||||
(action.type !== 'action' ||
|
||||
action.kind !== 'warn' ||
|
||||
action.state !== 'confirmed')
|
||||
)
|
||||
throw new AuthError('Warning issued must reference a confirmed warning.');
|
||||
if (outcome === 'restriction_applied' && action.type !== 'restriction')
|
||||
throw new AuthError(
|
||||
'Restriction applied must reference a recorded restriction.'
|
||||
);
|
||||
}
|
||||
evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
||||
)
|
||||
.run(outcome, now, row.id);
|
||||
moderationEvent(actor, `case.${outcome}`, row, row.id, reason, now);
|
||||
moderationEvent(
|
||||
actor,
|
||||
`case.${outcome}`,
|
||||
row,
|
||||
row.id,
|
||||
reference ? JSON.stringify({ reason, actionReference: reference }) : reason,
|
||||
now
|
||||
);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'reopen-case') {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { textField } from './core.js';
|
||||
import type { ModerationCaseAction, ModerationGame } from '../../moderation.js';
|
||||
|
||||
type Subject = { game: ModerationGame; guid: string };
|
||||
export type CaseActionReference = Pick<ModerationCaseAction, 'type' | 'id'>;
|
||||
|
||||
export function caseActionReference(value: unknown): CaseActionReference {
|
||||
if (!value || typeof value !== 'object') throw new AuthError('Choose a recorded action.');
|
||||
const reference = value as Record<string, unknown>;
|
||||
if (reference.type !== 'action' && reference.type !== 'restriction')
|
||||
throw new AuthError('Choose a recorded warning, action, or restriction.');
|
||||
return { type: reference.type, id: textField(reference.id, 'Action ID', 128) };
|
||||
}
|
||||
|
||||
const restrictionColumns = `id,'restriction' AS type,kind,reason,actor,
|
||||
created_at AS createdAt,expires_at AS expiresAt,
|
||||
CASE WHEN revoked_at IS NOT NULL THEN 'revoked'
|
||||
WHEN expires_at IS NOT NULL AND expires_at<=? THEN 'expired' ELSE 'active' END AS state`;
|
||||
const actionColumns = `id,'action' AS type,kind,reason,actor,
|
||||
created_at AS createdAt,expires_at AS expiresAt,state`;
|
||||
|
||||
/** Only actual records for this account can become case evidence. */
|
||||
export function recordedCaseAction(
|
||||
subject: Subject,
|
||||
reference: CaseActionReference,
|
||||
now: string
|
||||
): ModerationCaseAction {
|
||||
const handle = db();
|
||||
const action =
|
||||
reference.type === 'restriction'
|
||||
? handle
|
||||
.prepare(
|
||||
`SELECT ${restrictionColumns} FROM moderation_sanctions
|
||||
WHERE id=? AND guid=? AND (game=? OR scope='fleet')`
|
||||
)
|
||||
.get(now, reference.id, subject.guid, subject.game)
|
||||
: handle
|
||||
.prepare(
|
||||
`SELECT ${actionColumns} FROM moderation_actions
|
||||
WHERE id=? AND guid=? AND game=?`
|
||||
)
|
||||
.get(reference.id, subject.guid, subject.game);
|
||||
if (!action)
|
||||
throw new AuthError('The recorded action is unavailable for this case’s player.', 404);
|
||||
return action as ModerationCaseAction;
|
||||
}
|
||||
|
||||
export function isCaseActionLinked(caseId: string, reference: CaseActionReference): boolean {
|
||||
return Boolean(
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM moderation_case_action_links
|
||||
WHERE case_id=? AND action_type=? AND action_id=?
|
||||
UNION ALL SELECT 1 FROM moderation_sanctions
|
||||
WHERE ?='restriction' AND id=? AND case_id=? LIMIT 1`
|
||||
)
|
||||
.get(caseId, reference.type, reference.id, reference.type, reference.id, caseId)
|
||||
);
|
||||
}
|
||||
|
||||
export function caseActionLists(caseId: string, subject: Subject, now: string) {
|
||||
const handle = db();
|
||||
const records = handle
|
||||
.prepare(
|
||||
`SELECT ${restrictionColumns},
|
||||
(case_id=? OR EXISTS (SELECT 1 FROM moderation_case_action_links l
|
||||
WHERE l.case_id=? AND l.action_type='restriction' AND l.action_id=moderation_sanctions.id)) AS linked
|
||||
FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet')
|
||||
UNION ALL SELECT ${actionColumns},
|
||||
EXISTS (SELECT 1 FROM moderation_case_action_links l
|
||||
WHERE l.case_id=? AND l.action_type='action' AND l.action_id=moderation_actions.id) AS linked
|
||||
FROM moderation_actions WHERE guid=? AND game=?
|
||||
ORDER BY createdAt DESC,id`
|
||||
)
|
||||
.all(
|
||||
now,
|
||||
caseId,
|
||||
caseId,
|
||||
subject.guid,
|
||||
subject.game,
|
||||
caseId,
|
||||
subject.guid,
|
||||
subject.game
|
||||
) as Array<ModerationCaseAction & { linked: number | null }>;
|
||||
const linkedActions: ModerationCaseAction[] = [];
|
||||
const availableActions: ModerationCaseAction[] = [];
|
||||
for (const { linked, ...record } of records)
|
||||
(linked ? linkedActions : availableActions).push(record);
|
||||
return { linkedActions, availableActions };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { db } from '../db/index.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { moderationActor, textField } from './core.js';
|
||||
import { moderationOverview } from './overview.js';
|
||||
import { caseActionLists } from './case-actions.js';
|
||||
import type {
|
||||
ModerationCase,
|
||||
ModerationCaseDetail,
|
||||
@@ -42,9 +43,11 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
||||
.prepare(
|
||||
`SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note,
|
||||
created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision
|
||||
FROM moderation_sanctions WHERE case_id=? ORDER BY created_at DESC,id`
|
||||
FROM moderation_sanctions WHERE case_id=? OR id IN
|
||||
(SELECT action_id FROM moderation_case_action_links WHERE case_id=? AND action_type='restriction')
|
||||
ORDER BY created_at DESC,id`
|
||||
)
|
||||
.all(caseId) as Omit<ModerationSanction, 'active'>[]
|
||||
.all(caseId, caseId) as Omit<ModerationSanction, 'active'>[]
|
||||
).map((restriction) => ({
|
||||
...restriction,
|
||||
active:
|
||||
@@ -55,9 +58,10 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
||||
.prepare(
|
||||
`SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt
|
||||
FROM moderation_events WHERE subject_id=? OR subject_id IN
|
||||
(SELECT id FROM moderation_sanctions WHERE case_id=?) ORDER BY id DESC`
|
||||
(SELECT id FROM moderation_sanctions WHERE case_id=?) OR subject_id IN
|
||||
(SELECT action_id FROM moderation_case_action_links WHERE case_id=?) ORDER BY id DESC`
|
||||
)
|
||||
.all(caseId, caseId) as ModerationEvent[];
|
||||
.all(caseId, caseId, caseId) as ModerationEvent[];
|
||||
const assignees = handle
|
||||
.prepare(
|
||||
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
|
||||
@@ -77,6 +81,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
||||
lastSeen: null
|
||||
},
|
||||
sanctions,
|
||||
...caseActionLists(caseId, row, overview.checkedAt),
|
||||
events,
|
||||
coverage: overview.coverage,
|
||||
assignees,
|
||||
|
||||
@@ -149,3 +149,19 @@ export function migrateModerationCases(handle: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id);
|
||||
`);
|
||||
}
|
||||
|
||||
/** Links record prior enforcement without changing its original case or issuing it again. */
|
||||
export function migrateModerationCaseActions(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS moderation_case_action_links (
|
||||
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||
action_type TEXT NOT NULL CHECK(action_type IN ('action','restriction')),
|
||||
action_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(case_id,action_type,action_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS moderation_case_action_source
|
||||
ON moderation_case_action_links(action_type,action_id);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -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 ModerationGame,
|
||||
type ModerationScope,
|
||||
type ModerationCase
|
||||
type ModerationCase,
|
||||
type ModerationCaseAction
|
||||
} 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 { confirmAction } from '$ui/confirm.js';
|
||||
import Dialog from '../primitives/Dialog.svelte';
|
||||
@@ -26,6 +28,7 @@
|
||||
admin,
|
||||
seed = {},
|
||||
assignees = [],
|
||||
linkedActions = [],
|
||||
onsubmitted
|
||||
}: {
|
||||
open?: boolean;
|
||||
@@ -43,6 +46,7 @@
|
||||
coverage: ModerationCoverage[];
|
||||
admin: boolean;
|
||||
assignees?: { id: string; username: string }[];
|
||||
linkedActions?: ModerationCaseAction[];
|
||||
seed?: {
|
||||
game?: ModerationGame;
|
||||
guid?: string;
|
||||
@@ -81,6 +85,21 @@
|
||||
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
||||
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
||||
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 error = $state('');
|
||||
let uncertain = $state(false);
|
||||
@@ -203,7 +222,10 @@
|
||||
id: seed.id,
|
||||
revision: seed.revision,
|
||||
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
|
||||
change enforcement or send a warning.
|
||||
</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">
|
||||
Reopening returns the case to Open and preserves its evidence, restrictions, and
|
||||
earlier decisions.
|
||||
|
||||
@@ -70,10 +70,18 @@ export const moderationActionLabels: Record<string, string> = {
|
||||
'case.updated': 'Case updated',
|
||||
'case.evidence_attached': 'Evidence attached',
|
||||
'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',
|
||||
'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 {
|
||||
if (action.startsWith('case.')) {
|
||||
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}.`;
|
||||
if (action === 'case.restriction' && typeof value.reason === 'string')
|
||||
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 {
|
||||
// Older case events and decision reasons are plain staff-authored text.
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import {
|
||||
moderationActionLabels,
|
||||
moderationActionName,
|
||||
moderationEventDetail,
|
||||
moderationGameName,
|
||||
moderationHref
|
||||
@@ -29,6 +30,7 @@
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
|
||||
import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte';
|
||||
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
|
||||
import { preservedConversation } from '$ui/moderation-evidence.js';
|
||||
|
||||
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
|
||||
@@ -55,6 +57,7 @@
|
||||
let generation = 0;
|
||||
let controller: AbortController | undefined;
|
||||
let dialogOpen = $state(false);
|
||||
let linkActionOpen = $state(false);
|
||||
let dialogKey = $state(0);
|
||||
let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note');
|
||||
let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({});
|
||||
@@ -67,6 +70,7 @@
|
||||
refreshError = '';
|
||||
notice = '';
|
||||
dialogOpen = false;
|
||||
linkActionOpen = false;
|
||||
updatedAt = Date.parse(initial.checkedAt);
|
||||
});
|
||||
});
|
||||
@@ -149,6 +153,12 @@
|
||||
cancelRefresh();
|
||||
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) {
|
||||
if (!raw) return null;
|
||||
@@ -218,8 +228,8 @@
|
||||
</SectionHeader>
|
||||
|
||||
{#if !permitted}<p class="notice">
|
||||
Read-only moderation access. You can inspect this case, its evidence, and linked
|
||||
restrictions.
|
||||
Read-only moderation access. You can inspect this case, its evidence, and linked actions
|
||||
and restrictions.
|
||||
</p>{/if}
|
||||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
{#if refreshError}<div class="ui-error" role="alert">
|
||||
@@ -400,6 +410,45 @@
|
||||
>
|
||||
</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">
|
||||
<div class="section-heading">
|
||||
<h2 id="restrictions-title">Linked restrictions</h2>
|
||||
@@ -466,9 +515,7 @@
|
||||
})}>Revoke restriction</Button
|
||||
>{/if}
|
||||
</li>{/each}
|
||||
</ol>{:else}<p class="hint">
|
||||
No restrictions have been applied from this case.
|
||||
</p>{/if}
|
||||
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
|
||||
<p class="hint">
|
||||
Applying or revoking a restriction does not change the case outcome.
|
||||
</p>
|
||||
@@ -506,9 +553,18 @@
|
||||
{admin}
|
||||
coverage={report.coverage}
|
||||
assignees={report.assignees}
|
||||
linkedActions={report.linkedActions}
|
||||
onsubmitted={submitted}
|
||||
/>{/key}{/if}
|
||||
|
||||
{#if permitted && linkActionOpen}<LinkCaseActionDialog
|
||||
bind:open={linkActionOpen}
|
||||
caseId={item.id}
|
||||
revision={item.revision}
|
||||
actions={report.availableActions ?? []}
|
||||
onsubmitted={actionLinked}
|
||||
/>{/if}
|
||||
|
||||
<style>
|
||||
.case-page,
|
||||
.case-main,
|
||||
|
||||
Reference in New Issue
Block a user