Link prior moderation actions to case decisions
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user