Add related cases and duplicate review suggestions

This commit is contained in:
2026-10-17 19:52:50 -04:00
parent 428aefdf24
commit 90b94911c5
15 changed files with 1289 additions and 1 deletions
@@ -0,0 +1,193 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
moderationCaseRelations,
moderationCaseRelationLabels,
type ModerationCaseDetail,
type ModerationCaseRelationship,
type ModerationCaseRelation
} from '$lib/moderation.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
let {
open = $bindable(false),
caseId,
revision,
candidates = [],
relationship,
onsubmitted
}: {
open?: boolean;
caseId: string;
revision: number;
candidates?: NonNullable<ModerationCaseDetail['candidateCases']>;
relationship?: ModerationCaseRelationship;
onsubmitted: () => void;
} = $props();
const openedRevision = untrack(() => revision);
let selected = $state('');
let caseReference = $state('');
let relation = $state<ModerationCaseRelation>('same_incident');
let reason = $state('');
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
function relatedId(): string {
if (selected) return selected;
const value = caseReference.trim();
if (value.startsWith('/') || /^https?:\/\//i.test(value)) {
const path = new URL(value, 'http://localhost').pathname;
const match = /^\/moderation\/cases\/([^/]+)\/?$/.exec(path);
if (!match) throw new Error('Enter a case ID or a moderation case URL.');
return decodeURIComponent(match[1]);
}
return value;
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
error = '';
if (!pending) {
try {
const target = relationship ? '' : relatedId();
if ((!relationship && !target) || !reason.trim())
throw new Error('Choose a related case and provide a reason for this change.');
pending = {
id: caseId,
revision: openedRevision,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID(),
...(relationship
? { op: 'unlink-case', relationshipId: relationship.id }
: { op: 'link-case', relatedCaseId: target, relation })
};
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Enter a valid case reference.';
return;
}
}
busy = true;
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 save the case relationship.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not save the case relationship.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={relationship ? 'Remove case relationship' : 'Link related case'}
{busy}
description="Relationships connect case records while preserving each case and its history."
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
{#if relationship}<p>
Remove the relationship with <strong>{relationship.case.title}</strong>. The
relationship history remains recorded.
</p>
{:else}
<Field label="Related case"
><Select
bind:value={selected}
options={[
{ value: '', label: 'Enter a case ID or URL' },
...candidates.map((item) => ({
value: item.id,
label: `${item.title}${item.sharedMatch ? ' · Shared archived match' : ''}`
}))
]}
/></Field
>
{#if !selected}<Field label="Case ID or URL" required
><Input bind:value={caseReference} required maxlength={2048} /></Field
>{/if}
<Field label="Relationship"
><Select
bind:value={relation}
options={moderationCaseRelations.map((value) => ({
value,
label: moderationCaseRelationLabels[value]
}))}
/></Field
>
<p class="hint">
Appeal and duplicate relationships refer to the same player. “Same incident” can
connect cases involving different players.
</p>
{/if}
<Field label="Relationship reason" required
><Textarea bind:value={reason} required rows={3} maxlength={4000} /></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'
: relationship
? 'Remove relationship'
: 'Link cases'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
p {
margin: 0;
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
</style>