Link prior moderation actions to case decisions

This commit is contained in:
2026-10-17 19:42:05 -04:00
parent ef96a2c308
commit aca728b557
13 changed files with 948 additions and 17 deletions
@@ -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.