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
+12
View File
@@ -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;
+11 -2
View File
@@ -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;
+60 -2
View File
@@ -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') {
+92
View File
@@ -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 };
}
+9 -4
View File
@@ -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,
+16
View File
@@ -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.
+20
View File
@@ -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.
}
+61 -5
View File
@@ -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,