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
+10
View File
@@ -147,6 +147,16 @@ the evidence; the case's workflow status and final outcome remain separate.
Every saved assessment retains its author, timestamp, and complete previous Every saved assessment retains its author, timestamp, and complete previous
revision. Assessments can be revised after closure without reopening the case. revision. Assessments can be revised after closure without reopening the case.
Before opening a case, review existing cases for the same game and GUID. A case
also shows same-player candidates; a shared archived match is a useful potential
duplicate signal, not a finding that two reports are identical. Link cases as
**Same incident**, **Appeal of**, or **Duplicate of**, recording why they belong
together. Same-incident links may include different players; appeals and duplicates
must concern the same game and player. Links appear from both cases with their
direction made explicit. Self-links and circular appeal/duplicate chains are
rejected. Removing a mistaken relationship requires a reason and preserves its
history. Relationship changes do not close or reopen either case.
Apply a ban or mute directly from an active case. The case fixes the target game Apply a ban or mute directly from an active case. The case fixes the target game
and GUID and records the resulting restriction alongside the evidence. The same and GUID and records the resulting restriction alongside the evidence. The same
role, scope, and duration limits apply as elsewhere in Moderation. Applying a role, scope, and duration limits apply as elsewhere in Moderation. Applying a
+24
View File
@@ -24,6 +24,13 @@ export const moderationCaseStatuses = [
] as const; ] as const;
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number]; export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent'; export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
export type ModerationCaseRelation = (typeof moderationCaseRelations)[number];
export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string> = {
same_incident: 'Same incident',
appeal_of: 'Appeal of',
duplicate_of: 'Duplicate of'
};
export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = { export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = {
open: 'Open', open: 'Open',
in_review: 'In review', in_review: 'In review',
@@ -173,11 +180,28 @@ export interface ModerationCaseDetail {
availableActions?: ModerationCaseAction[]; availableActions?: ModerationCaseAction[];
assessment?: ModerationCaseAssessment | null; assessment?: ModerationCaseAssessment | null;
assessmentHistory?: ModerationCaseAssessment[]; assessmentHistory?: ModerationCaseAssessment[];
relatedCases?: ModerationCaseRelationship[];
candidateCases?: ModerationCaseCandidate[];
events: ModerationEvent[]; events: ModerationEvent[];
coverage: ModerationCoverage[]; coverage: ModerationCoverage[];
assignees: { id: string; username: string }[]; assignees: { id: string; username: string }[];
checkedAt: string; checkedAt: string;
} }
export type ModerationCaseReference = Pick<
ModerationCase,
'id' | 'title' | 'status' | 'game' | 'guid'
>;
export interface ModerationCaseRelationship {
id: string;
relation: ModerationCaseRelation;
direction: 'outgoing' | 'incoming';
case: ModerationCaseReference;
actor: string;
createdAt: string;
}
export interface ModerationCaseCandidate extends ModerationCaseReference {
sharedMatch: boolean;
}
export interface ModerationCaseAction { export interface ModerationCaseAction {
id: string; id: string;
type: 'restriction' | 'action'; type: 'restriction' | 'action';
+7 -1
View File
@@ -32,7 +32,8 @@ import {
migrateModeration, migrateModeration,
migrateModerationCases, migrateModerationCases,
migrateModerationCaseActions, migrateModerationCaseActions,
migrateModerationCaseAssessments migrateModerationCaseAssessments,
migrateModerationCaseRelationships
} from '../moderation/schema.js'; } from '../moderation/schema.js';
import type { Migration } from './migration-runner.js'; import type { Migration } from './migration-runner.js';
@@ -185,6 +186,11 @@ export const migrations: readonly Migration[] = [
version: 32, version: 32,
name: 'moderation case assessment revision history', name: 'moderation case assessment revision history',
run: migrateModerationCaseAssessments run: migrateModerationCaseAssessments
},
{
version: 33,
name: 'moderation case relationships and duplicate review',
run: migrateModerationCaseRelationships
} }
]; ];
+16
View File
@@ -3,6 +3,7 @@ import { db } from '../db/index.js';
import { archivedEvidenceSnapshots } from './evidence.js'; import { archivedEvidenceSnapshots } from './evidence.js';
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js'; import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
import { assessmentDraft } from './assessments.js'; import { assessmentDraft } from './assessments.js';
import { linkCases, unlinkCases } from './relationships.js';
import { AuthError } from '../auth/index.js'; import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import { import {
@@ -473,6 +474,21 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
if (input.op === 'action') return liveAction(actor, input, now); if (input.op === 'action') return liveAction(actor, input, now);
if (input.op === 'create-case') return createCase(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 === 'update-case') return updateCase(actor, input, now);
if (input.op === 'link-case' || input.op === 'unlink-case') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
if (input.op === 'link-case')
linkCases(
actor,
row,
caseRow(input.relatedCaseId),
input.relation,
input.reason,
now
);
else unlinkCases(actor, row, input.relationshipId, input.reason, now);
return { id: row.id };
}
if (input.op === 'assess-case') { if (input.op === 'assess-case') {
const row = caseRow(input.id); const row = caseRow(input.id);
checkCaseRevision(row, input.revision); checkCaseRevision(row, input.revision);
+3
View File
@@ -4,6 +4,7 @@ import { moderationActor, textField } from './core.js';
import { moderationOverview } from './overview.js'; import { moderationOverview } from './overview.js';
import { caseActionLists } from './case-actions.js'; import { caseActionLists } from './case-actions.js';
import { caseAssessmentHistory } from './assessments.js'; import { caseAssessmentHistory } from './assessments.js';
import { relatedCases, candidateCases } from './relationships.js';
import type { import type {
ModerationCase, ModerationCase,
ModerationCaseDetail, ModerationCaseDetail,
@@ -86,6 +87,8 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
...caseActionLists(caseId, row, overview.checkedAt), ...caseActionLists(caseId, row, overview.checkedAt),
assessment: assessmentHistory[0] ?? null, assessment: assessmentHistory[0] ?? null,
assessmentHistory, assessmentHistory,
relatedCases: relatedCases(caseId),
candidateCases: candidateCases(row),
events, events,
coverage: overview.coverage, coverage: overview.coverage,
assignees, assignees,
+212
View File
@@ -0,0 +1,212 @@
import { randomUUID } from 'node:crypto';
import type { AuthUser } from '../../auth.js';
import {
moderationCaseRelations,
moderationGuid,
type ModerationCaseCandidate,
type ModerationCaseReference,
type ModerationCaseRelation,
type ModerationCaseRelationship
} from '../../moderation.js';
import { AuthError } from '../auth/index.js';
import { db } from '../db/index.js';
import { moderationEvent, textField } from './core.js';
type RelationshipRow = {
id: string;
case_id: string;
target_case_id: string;
relation: ModerationCaseRelation;
removed_at: string | null;
};
function relationAudit(
actor: AuthUser,
relationship: RelationshipRow,
cases: ModerationCaseReference[],
action: 'case.linked' | 'case.unlinked',
reason: string,
now: string
) {
db()
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id IN (?,?)')
.run(now, relationship.case_id, relationship.target_case_id);
for (const item of cases) {
const outgoing = item.id === relationship.case_id;
moderationEvent(
actor,
action,
item,
item.id,
JSON.stringify({
relationshipId: relationship.id,
relation: relationship.relation,
direction: outgoing ? 'outgoing' : 'incoming',
relatedCaseId: outgoing ? relationship.target_case_id : relationship.case_id,
reason
}),
now
);
}
}
export function linkCases(
actor: AuthUser,
source: ModerationCaseReference,
target: ModerationCaseReference,
value: unknown,
reasonValue: unknown,
now: string
) {
const relation = moderationCaseRelations.find((relation) => relation === value);
if (!relation) throw new AuthError('Choose same incident, appeal of, or duplicate of.');
const reason = textField(reasonValue, 'Relationship reason', 4000);
if (source.id === target.id) throw new AuthError('A case cannot be related to itself.');
if (
relation !== 'same_incident' &&
(source.game !== target.game || source.guid !== target.guid)
)
throw new AuthError('Appeal and duplicate relationships require the same game and player.');
const [from, to] =
relation === 'same_incident' && source.id > target.id ? [target, source] : [source, target];
const handle = db();
if (
handle
.prepare(
`SELECT 1 FROM moderation_case_relationships
WHERE case_id=? AND target_case_id=? AND relation=? AND removed_at IS NULL`
)
.get(from.id, to.id, relation)
)
return;
if (
relation !== 'same_incident' &&
handle
.prepare(
`WITH RECURSIVE descendants(id) AS (
SELECT target_case_id FROM moderation_case_relationships
WHERE case_id=? AND relation=? AND removed_at IS NULL
UNION
SELECT r.target_case_id FROM moderation_case_relationships r
JOIN descendants d ON r.case_id=d.id WHERE r.relation=? AND r.removed_at IS NULL
) SELECT 1 FROM descendants WHERE id=? LIMIT 1`
)
.get(to.id, relation, relation, from.id)
)
throw new AuthError(
'This relationship would create a cycle. Review the linked cases.',
409
);
const relationship: RelationshipRow = {
id: randomUUID(),
case_id: from.id,
target_case_id: to.id,
relation,
removed_at: null
};
handle
.prepare('INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)')
.run(relationship.id, from.id, to.id, relation, actor.id, actor.username, now);
relationAudit(actor, relationship, [from, to], 'case.linked', reason, now);
}
export function unlinkCases(
actor: AuthUser,
source: ModerationCaseReference,
value: unknown,
reasonValue: unknown,
now: string
) {
const id = textField(value, 'Relationship ID', 128);
const reason = textField(reasonValue, 'Removal reason', 4000);
const handle = db();
const relationship = handle
.prepare(
`SELECT id,case_id,target_case_id,relation,removed_at
FROM moderation_case_relationships WHERE id=? AND (case_id=? OR target_case_id=?)`
)
.get(id, source.id, source.id) as RelationshipRow | undefined;
if (!relationship) throw new AuthError('The case relationship is unavailable.', 404);
if (relationship.removed_at) return;
const otherId =
relationship.case_id === source.id ? relationship.target_case_id : relationship.case_id;
const other = handle
.prepare('SELECT id,title,status,game,guid FROM moderation_cases WHERE id=?')
.get(otherId) as ModerationCaseReference;
handle.prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?').run(now, id);
relationAudit(actor, relationship, [source, other], 'case.unlinked', reason, now);
}
/** Relationships remain readable from both endpoints, with the original direction intact. */
export function relatedCases(caseId: string): ModerationCaseRelationship[] {
const rows = db()
.prepare(
`SELECT r.id,r.relation,r.actor,r.created_at AS createdAt,
CASE WHEN r.case_id=? THEN 'outgoing' ELSE 'incoming' END AS direction,
c.id AS otherId,c.title,c.status,c.game,c.guid
FROM moderation_case_relationships r JOIN moderation_cases c
ON c.id=CASE WHEN r.case_id=? THEN r.target_case_id ELSE r.case_id END
WHERE (r.case_id=? OR r.target_case_id=?) AND r.removed_at IS NULL
ORDER BY r.created_at DESC,r.id`
)
.all(caseId, caseId, caseId, caseId) as Array<
Omit<ModerationCaseRelationship, 'case'> &
Omit<ModerationCaseReference, 'id'> & { otherId: string }
>;
return rows.map(({ otherId, title, status, game, guid, ...relationship }) => ({
...relationship,
case: { id: otherId, title, status, game, guid }
}));
}
function archivedMatch(value: string, subject: ModerationCaseReference): number | null {
try {
const snapshot = JSON.parse(value);
if (
!Number.isSafeInteger(snapshot?.matchId) ||
snapshot.matchId < 1 ||
snapshot.match?.game !== subject.game ||
typeof snapshot.match?.server !== 'string' ||
typeof snapshot.match?.started_at !== 'string' ||
snapshot.entry?.type !== 'message' ||
typeof snapshot.entry.message?.guid !== 'string' ||
typeof snapshot.entry.message?.message !== 'string' ||
moderationGuid(subject.game, snapshot.entry.message.guid) !== subject.guid
)
return null;
return snapshot.matchId;
} catch {
return null;
}
}
/** Sharing archived match context is a review hint, never a duplicate decision. */
export function candidateCases(subject: ModerationCaseReference): ModerationCaseCandidate[] {
const handle = db();
const candidates = handle
.prepare(
`SELECT id,title,status,game,guid FROM moderation_cases
WHERE game=? AND guid=? AND id<>? ORDER BY updated_at DESC,id LIMIT 25`
)
.all(subject.game, subject.guid, subject.id) as ModerationCaseReference[];
const ids = [subject.id, ...candidates.map((candidate) => candidate.id)];
const snapshots = handle
.prepare(
`SELECT case_id,snapshot FROM moderation_evidence
WHERE case_id IN (${ids.map(() => '?').join(',')}) AND snapshot IS NOT NULL`
)
.all(...ids) as { case_id: string; snapshot: string }[];
const matches = new Map<string, Set<number>>();
for (const snapshot of snapshots) {
const match = archivedMatch(snapshot.snapshot, subject);
if (match === null) continue;
const set = matches.get(snapshot.case_id) ?? new Set<number>();
set.add(match);
matches.set(snapshot.case_id, set);
}
const current = matches.get(subject.id) ?? new Set<number>();
return candidates.map((candidate) => ({
...candidate,
sharedMatch: [...(matches.get(candidate.id) ?? [])].some((match) => current.has(match))
}));
}
+23
View File
@@ -179,3 +179,26 @@ export function migrateModerationCaseAssessments(handle: Database.Database): voi
); );
`); `);
} }
export function migrateModerationCaseRelationships(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_relationships (
id TEXT PRIMARY KEY,
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
target_case_id TEXT NOT NULL REFERENCES moderation_cases(id),
relation TEXT NOT NULL CHECK(relation IN ('same_incident','appeal_of','duplicate_of')),
actor_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
removed_at TEXT,
CHECK(case_id != target_case_id),
CHECK(relation != 'same_incident' OR case_id < target_case_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS moderation_case_relationship_active
ON moderation_case_relationships(case_id,target_case_id,relation) WHERE removed_at IS NULL;
CREATE INDEX IF NOT EXISTS moderation_case_relationship_target
ON moderation_case_relationships(target_case_id,removed_at);
CREATE INDEX IF NOT EXISTS moderation_case_subject_recent
ON moderation_cases(game,guid,updated_at DESC,id);
`);
}
@@ -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>
@@ -0,0 +1,153 @@
<script lang="ts">
import {
moderationCaseRelationLabels,
moderationCaseStatusLabels,
type ModerationCaseDetail,
type ModerationCaseRelationship
} from '$lib/moderation.js';
import { moderationCaseHref } from '$ui/moderation.js';
import Button from '../primitives/Button.svelte';
import Badge from '../primitives/Badge.svelte';
import RelativeTime from '../RelativeTime.svelte';
import CaseRelationshipDialog from './CaseRelationshipDialog.svelte';
let {
caseId,
revision,
relationships = [],
candidates = [],
permitted,
onsubmitted
}: {
caseId: string;
revision: number;
relationships?: ModerationCaseRelationship[];
candidates?: NonNullable<ModerationCaseDetail['candidateCases']>;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let open = $state(false);
let removing = $state<ModerationCaseRelationship | undefined>();
$effect(() => {
void caseId;
open = false;
});
function label(relationship: ModerationCaseRelationship) {
if (relationship.direction === 'incoming') {
if (relationship.relation === 'appeal_of') return 'Appealed by';
if (relationship.relation === 'duplicate_of') return 'Has duplicate';
}
return moderationCaseRelationLabels[relationship.relation];
}
</script>
<section aria-label="Related cases">
<div class="heading">
<h2>Related cases</h2>
{#if permitted}<Button
size="sm"
onclick={() => {
removing = undefined;
open = true;
}}>Link case</Button
>{/if}
</div>
{#if relationships.length}<ul class="records">
{#each relationships as relationship (relationship.id)}<li>
<div class="heading">
<strong>{label(relationship)}</strong><Badge
>{moderationCaseStatusLabels[relationship.case.status]}</Badge
>
</div>
<a href={moderationCaseHref(relationship.case.id)}>{relationship.case.title}</a>
<p class="hint">
{relationship.actor} · <RelativeTime value={relationship.createdAt} />
</p>
{#if permitted}<Button
size="sm"
ariaLabel={`Remove relationship with ${relationship.case.title}`}
onclick={() => {
removing = relationship;
open = true;
}}>Remove link</Button
>{/if}
</li>{/each}
</ul>{:else}<p class="hint">No cases linked yet.</p>{/if}
{#if candidates.length}<details>
<summary>Other cases for this player ({candidates.length})</summary>
<ul class="records">
{#each candidates as candidate}<li>
<a href={moderationCaseHref(candidate.id)}>{candidate.title}</a>
<span class="hint">{moderationCaseStatusLabels[candidate.status]}</span>
{#if candidate.sharedMatch}<Badge variant="warning"
>Shared archived match</Badge
>
<p class="hint">
Review for a possible duplicate or related incident.
</p>{/if}
</li>{/each}
</ul>
</details>{/if}
</section>
{#if permitted && open}<CaseRelationshipDialog
bind:open
{caseId}
{revision}
{candidates}
relationship={removing}
{onsubmitted}
/>{/if}
<style>
section {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
margin: 0;
line-height: var(--leading-body);
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.records {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-3);
}
.records > li {
display: grid;
justify-items: start;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
details .records {
margin-top: var(--space-3);
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { ModerationGame, ModerationOverview } from '$lib/moderation.js';
import { moderationCaseStatusLabels } from '$lib/moderation.js';
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
let { game, guid }: { game: ModerationGame; guid: string } = $props();
let report = $state<ModerationOverview | null>(null);
let loading = $state(false);
let failed = $state(false);
let retry = $state(0);
$effect(() => {
const target = guid.trim();
const targetGame = game;
void retry;
const controller = new AbortController();
report = null;
failed = false;
loading = !!target;
const timer = setTimeout(async () => {
if (!target) return;
try {
const params = new URLSearchParams({
view: 'cases',
game: targetGame,
guid: target
});
const response = await fetch(`/api/moderation?${params}`, {
signal: controller.signal
});
if (!response.ok) throw new Error('Case lookup failed');
const result: ModerationOverview = await response.json();
if (!Array.isArray(result.cases) || !Number.isSafeInteger(result.total))
throw new Error('Invalid case lookup response');
if (!controller.signal.aborted) report = result;
} catch {
if (!controller.signal.aborted) failed = true;
} finally {
if (!controller.signal.aborted) loading = false;
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
});
</script>
<aside aria-label="Existing cases for this player">
{#if loading}<p role="status">Checking existing cases for this player…</p>
{:else if failed}<p role="status">
Existing cases could not be loaded. You can still open a case. <button
type="button"
onclick={() => retry++}>Retry lookup</button
>
</p>
{:else if report?.cases.length}
<strong
>{report.total} existing {report.total === 1 ? 'case' : 'cases'} for this player</strong
>
<p>Review these before opening another case.</p>
<ul>
{#each report.cases as item}<li>
<a href={moderationCaseHref(item.id)} target="_blank" rel="noreferrer"
>{item.title}</a
>
· {moderationCaseStatusLabels[item.status]}
</li>{/each}
</ul>
<a href={`${moderationHref({ game, guid })}&view=cases`} target="_blank" rel="noreferrer"
>View all cases for this player</a
>
{:else if report}<p>No existing cases found for this player.</p>{/if}
</aside>
<style>
aside {
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: var(--text-sm);
}
p {
margin: 0;
line-height: var(--leading-body);
}
ul {
margin: var(--space-2) 0;
padding-left: var(--space-5);
max-height: 200px;
overflow: auto;
}
li {
padding: var(--space-1) 0;
overflow-wrap: anywhere;
}
button {
border: 0;
background: none;
padding: 0;
color: var(--accent-primary);
font: inherit;
cursor: pointer;
text-decoration: underline;
}
</style>
@@ -20,6 +20,7 @@
import Select from '../primitives/Select.svelte'; import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte'; import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
import ExistingCaseNotice from './ExistingCaseNotice.svelte';
let { let {
open = $bindable(false), open = $bindable(false),
@@ -395,6 +396,10 @@
complete. complete.
</p>{/if} </p>{/if}
{#if mode === 'case' || mode === 'edit-case'} {#if mode === 'case' || mode === 'edit-case'}
{#if mode === 'case' && open && game && guid.trim()}<ExistingCaseNotice
{game}
{guid}
/>{/if}
<Field label="Case title" required <Field label="Case title" required
><Input bind:value={title} required maxlength={160} /></Field ><Input bind:value={title} required maxlength={160} /></Field
> >
+21
View File
@@ -1,5 +1,7 @@
import { import {
moderationCaseStatusLabels, moderationCaseStatusLabels,
moderationCaseRelationLabels,
type ModerationCaseRelation,
type ModerationCaseStatus, type ModerationCaseStatus,
type ModerationGame type ModerationGame
} from '$lib/moderation.js'; } from '$lib/moderation.js';
@@ -69,6 +71,10 @@ export const moderationActionLabels: Record<string, string> = {
'case.reopened': 'Case reopened', 'case.reopened': 'Case reopened',
'case.updated': 'Case updated', 'case.updated': 'Case updated',
'case.assessed': 'Assessment revised', 'case.assessed': 'Assessment revised',
'case.linked': 'Case relationship added',
'case.unlinked': 'Case relationship removed',
'link-case': 'Case relationship added',
'unlink-case': 'Case relationship removed',
'assess-case': 'Assessment revised', 'assess-case': 'Assessment revised',
'case.evidence_attached': 'Evidence attached', 'case.evidence_attached': 'Evidence attached',
'case.restriction': 'Restriction linked to case', 'case.restriction': 'Restriction linked to case',
@@ -98,6 +104,21 @@ export function moderationEventDetail(detail: string, action: string): string {
? moderationCaseStatusLabels[item as ModerationCaseStatus] ? moderationCaseStatusLabels[item as ModerationCaseStatus]
: item : item
: ''; : '';
if (
(action === 'case.linked' || action === 'case.unlinked') &&
typeof value.relation === 'string' &&
Object.hasOwn(moderationCaseRelationLabels, value.relation) &&
typeof value.relatedCaseId === 'string' &&
typeof value.reason === 'string'
) {
const label =
value.direction === 'incoming' && value.relation !== 'same_incident'
? value.relation === 'appeal_of'
? 'Appealed by'
: 'Has duplicate'
: moderationCaseRelationLabels[value.relation as ModerationCaseRelation];
return `${label}: ${value.relatedCaseId}\n${value.reason}`;
}
if (action === 'case.assessed' && typeof value.summary === 'string') if (action === 'case.assessed' && typeof value.summary === 'string')
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`; return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
if (action === 'case.updated') { if (action === 'case.updated') {
@@ -32,6 +32,7 @@
import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte'; import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte';
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte'; import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
import CaseAssessment from '$ui/components/moderation/CaseAssessment.svelte'; import CaseAssessment from '$ui/components/moderation/CaseAssessment.svelte';
import CaseRelationships from '$ui/components/moderation/CaseRelationships.svelte';
import { preservedConversation } from '$ui/moderation-evidence.js'; import { preservedConversation } from '$ui/moderation-evidence.js';
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props(); let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
@@ -395,6 +396,19 @@
</div> </div>
<aside class="case-sidebar"> <aside class="case-sidebar">
<CaseRelationships
caseId={item.id}
revision={item.revision}
relationships={report.relatedCases}
candidates={report.candidateCases}
{permitted}
onsubmitted={() => {
notice =
'Case relationship updated. The change is recorded in both case histories.';
cancelRefresh();
void refresh();
}}
/>
<section class="panel" aria-labelledby="player-title"> <section class="panel" aria-labelledby="player-title">
<h2 id="player-title">Player identity</h2> <h2 id="player-title">Player identity</h2>
<a class="player-name" href={contextHref(playerProfileHref(item.guid), $page.url)} <a class="player-name" href={contextHref(playerProfileHref(item.guid), $page.url)}
@@ -0,0 +1,187 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import CaseRelationships from '../../src/lib/ui/components/moderation/CaseRelationships.svelte';
import ModerationDialog from '../../src/lib/ui/components/moderation/ModerationDialog.svelte';
import type { ModerationCaseRelationship } from '../../src/lib/moderation.js';
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const candidate = {
id: 'case-2',
title: 'Earlier report',
status: 'open' as const,
game: 't5' as const,
guid: '123',
sharedMatch: true
};
const relationship: ModerationCaseRelationship = {
id: 'relationship-1',
relation: 'appeal_of',
direction: 'incoming',
case: candidate,
actor: 'Moderator One',
createdAt: '2026-10-17T10:00:00Z'
};
function show(permitted = true, relationships: ModerationCaseRelationship[] = []) {
const saved = vi.fn();
component = mount(CaseRelationships, {
target: document.body,
props: {
caseId: 'case-1',
revision: 6,
relationships,
candidates: [candidate],
permitted,
onsubmitted: saved
}
});
flushSync();
return saved;
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
label: string
): T {
const node = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
}
function enter(label: string, value: string) {
const node = field<HTMLInputElement | HTMLTextAreaElement>(label);
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
function select(label: string, value: string) {
const node = field<HTMLSelectElement>(label);
const query = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: query(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
it.each(['candidate', 'url'])(
'links a %s with an explicit relationship and reason',
async (source) => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
expect(document.body.textContent).toContain('Shared archived match');
button('Link case').click();
await tick();
if (source === 'candidate') {
select('Related case', 'case-2');
select('Relationship', 'duplicate_of');
} else enter('Case ID or URL', 'https://example.com/moderation/cases/case-2?view=history');
enter('Relationship reason', 'Both reports refer to the same exchange');
button('Link cases').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'link-case',
id: 'case-1',
revision: 6,
relatedCaseId: 'case-2',
relation: source === 'candidate' ? 'duplicate_of' : 'same_incident',
reason: 'Both reports refer to the same exchange',
idempotencyKey: expect.any(String)
});
}
);
it('displays inverse relationships and removes links only with a recorded reason', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show(true, [relationship]);
expect(document.body.textContent).toContain('Appealed by');
button('Remove link').click();
await tick();
expect(field<HTMLTextAreaElement>('Relationship reason').required).toBe(true);
enter('Relationship reason', 'Linked to the wrong incident');
button('Remove relationship').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'unlink-case',
id: 'case-1',
revision: 6,
relationshipId: 'relationship-1',
reason: 'Linked to the wrong incident'
});
});
it('lets read-only viewers inspect candidate hints and inverse relationships', () => {
show(false, [
relationship,
{ ...relationship, id: 'relationship-2', relation: 'duplicate_of' }
]);
expect(document.body.textContent).toContain('Appealed by');
expect(document.body.textContent).toContain('Has duplicate');
expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull();
expect(button('Link case')).toBeUndefined();
expect(button('Remove link')).toBeUndefined();
});
it('shows existing cases in the generic case creation dialog', async () => {
const fetch = vi.fn().mockResolvedValue(response({ cases: [candidate], total: 31 }));
vi.stubGlobal('fetch', fetch);
component = mount(ModerationDialog, {
target: document.body,
props: {
open: true,
mode: 'case',
coverage: [],
admin: false,
seed: { game: 't5', guid: '123' },
onsubmitted: vi.fn()
}
});
flushSync();
await vi.waitFor(() => expect(document.body.textContent).toContain('31 existing cases'));
expect(fetch.mock.calls[0][0]).toBe('/api/moderation?view=cases&game=t5&guid=123');
expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull();
expect(document.body.textContent).toContain('View all cases for this player');
});
it('allows case creation when duplicate lookup fails', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Lookup unavailable'))
.mockResolvedValue(response({ ok: true, id: 'new-case' }));
vi.stubGlobal('fetch', fetch);
const saved = vi.fn();
component = mount(ModerationDialog, {
target: document.body,
props: {
open: true,
mode: 'case',
coverage: [],
admin: false,
seed: { game: 't5', guid: '123' },
onsubmitted: saved
}
});
flushSync();
await vi.waitFor(() =>
expect(document.body.textContent).toContain('Existing cases could not be loaded')
);
enter('Case title', 'Distinct incident');
button('Save').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Distinct incident'
});
});
@@ -0,0 +1,315 @@
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'super_viewer', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'moderator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
const create = (overrides: Record<string, unknown> = {}) =>
mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review incident',
...overrides
});
const link = (id: string, revision: number, relatedCaseId: string, relation = 'same_incident') => ({
op: 'link-case',
id,
revision,
relatedCaseId,
relation,
reason: 'Evidence connects these reviews.'
});
const unlink = (id: string, revision: number, relationshipId: string) => ({
op: 'unlink-case',
id,
revision,
relationshipId,
reason: 'The investigations concern different incidents.'
});
async function linkCurrent(id: string, related: string, relation: string) {
return mutate(link(id, (await detail(id)).case.revision, related, relation));
}
it('records symmetric incident links once, preserving both players and advancing both case revisions', async () => {
const a = await create();
const b = await create({ game: 't6', guid: '456', title: 'Connected player' });
const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() };
await mutate(request);
await mutate(request);
const first = await detail(a.id);
const second = await detail(b.id, 'super_viewer');
expect(first.case).toMatchObject({ revision: 2, game: 't5', guid: '123', status: 'open' });
expect(second.case).toMatchObject({ revision: 2, game: 't6', guid: '456', status: 'open' });
expect(first.relatedCases).toMatchObject([
{ relation: 'same_incident', case: { id: b.id, title: 'Connected player' } }
]);
expect(second.relatedCases).toMatchObject([
{ id: first.relatedCases![0].id, case: { id: a.id } }
]);
expect(new Set([first.relatedCases![0].direction, second.relatedCases![0].direction])).toEqual(
new Set(['incoming', 'outgoing'])
);
await mutate(link(b.id, 2, a.id));
expect(await detail(a.id)).toEqual(first);
expect(await detail(b.id)).toEqual(second);
expect(
handle.prepare('SELECT case_id,target_case_id FROM moderation_case_relationships').all()
).toEqual([{ case_id: [a.id, b.id].sort()[0], target_case_id: [a.id, b.id].sort()[1] }]);
expect(JSON.parse(first.events[0].detail)).toMatchObject({
relation: 'same_incident',
relatedCaseId: b.id,
reason: request.reason
});
await expect(
mutate({ op: 'case-note', id: b.id, revision: 1, text: 'Stale note' })
).rejects.toMatchObject({ status: 409 });
});
it('retains directed appeal and duplicate relationships while rejecting cycles within each relation', async () => {
const a = await create();
const b = await create();
const c = await create();
await linkCurrent(a.id, b.id, 'duplicate_of');
await linkCurrent(b.id, c.id, 'duplicate_of');
await expect(linkCurrent(c.id, a.id, 'duplicate_of')).rejects.toThrow('cycle');
await expect(linkCurrent(b.id, a.id, 'duplicate_of')).rejects.toThrow('cycle');
await linkCurrent(b.id, a.id, 'appeal_of');
await expect(linkCurrent(a.id, b.id, 'appeal_of')).rejects.toThrow('cycle');
expect((await detail(a.id)).relatedCases).toEqual(
expect.arrayContaining([
expect.objectContaining({
relation: 'duplicate_of',
direction: 'outgoing',
case: expect.objectContaining({ id: b.id })
}),
expect.objectContaining({
relation: 'appeal_of',
direction: 'incoming',
case: expect.objectContaining({ id: b.id })
})
])
);
const relationship = (await detail(b.id)).relatedCases!.find(
(entry) => entry.relation === 'duplicate_of' && entry.direction === 'outgoing'
)!;
await mutate(unlink(b.id, (await detail(b.id)).case.revision, relationship.id));
await linkCurrent(c.id, a.id, 'duplicate_of');
expect((await detail(c.id)).relatedCases).toMatchObject([
{ relation: 'duplicate_of', direction: 'outgoing', case: { id: a.id } }
]);
});
it('enforces case existence, staff access, revisions, self-link rejection and subject boundaries', async () => {
const a = await create();
const b = await create({ guid: '456' });
const c = await create({ game: 't6' });
for (const actor of ['viewer', 'super_viewer'])
await expect(mutate(link(a.id, 1, b.id), actor)).rejects.toMatchObject({ status: 403 });
await expect(mutate(link(a.id, 2, b.id))).rejects.toMatchObject({ status: 409 });
await expect(mutate(link(a.id, 1, 'missing'))).rejects.toMatchObject({ status: 404 });
await expect(mutate(link(a.id, 1, a.id))).rejects.toThrow('itself');
await expect(mutate(link(a.id, 1, b.id, 'unknown'))).rejects.toMatchObject({ status: 400 });
for (const relation of ['duplicate_of', 'appeal_of'])
for (const other of [b.id, c.id])
await expect(mutate(link(a.id, 1, other, relation))).rejects.toThrow(
'same game and player'
);
expect((await detail(a.id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]);
await mutate({
op: 'resolve-case',
id: a.id,
revision: 1,
status: 'no_action',
reason: 'Review complete'
});
await mutate(link(a.id, 2, b.id));
expect((await detail(a.id)).case).toMatchObject({ status: 'no_action', revision: 3 });
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
});
it('removes relationships from either endpoint without erasing history and permits a fresh link later', async () => {
const a = await create();
const b = await create();
const unrelated = await create();
await mutate(link(a.id, 1, b.id, 'appeal_of'));
const relationship = (await detail(a.id)).relatedCases![0];
await expect(mutate(unlink(unrelated.id, 1, relationship.id))).rejects.toMatchObject({
status: 404
});
await expect(mutate(unlink(b.id, 1, relationship.id))).rejects.toMatchObject({ status: 409 });
await mutate(unlink(b.id, 2, relationship.id));
for (const item of [a, b]) {
const data = await detail(item.id);
expect(data.case.revision).toBe(3);
expect(data.relatedCases).toEqual([]);
expect(data.events.map((entry) => entry.action)).toEqual([
'case.unlinked',
'case.linked',
'case.opened'
]);
}
expect(
handle
.prepare('SELECT removed_at FROM moderation_case_relationships WHERE id=?')
.get(relationship.id)
).toEqual({ removed_at: clock });
await mutate(unlink(a.id, 3, relationship.id));
expect((await detail(a.id)).case.revision).toBe(3);
await mutate(link(a.id, 3, b.id, 'appeal_of'));
expect((await detail(a.id)).relatedCases![0].id).not.toBe(relationship.id);
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_relationships').get()
).toEqual({ count: 2 });
});
it('rolls back both case revisions, relationship changes and receipts when either audit fails', async () => {
const a = await create();
const b = await create();
handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events
WHEN NEW.action IN ('case.linked','case.unlinked') BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() };
await expect(mutate(request)).rejects.toThrow('audit unavailable');
expect((await detail(a.id)).case.revision).toBe(1);
expect((await detail(b.id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
handle.exec('DROP TRIGGER fail_relationship_audit');
await mutate(request);
const relationship = (await detail(a.id)).relatedCases![0];
handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.unlinked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
await expect(mutate(unlink(a.id, 2, relationship.id))).rejects.toThrow('audit unavailable');
expect((await detail(a.id)).case.revision).toBe(2);
expect((await detail(b.id)).case.revision).toBe(2);
expect(handle.prepare('SELECT removed_at FROM moderation_case_relationships').get()).toEqual({
removed_at: null
});
});
it('suggests recent same-player cases and treats valid shared archived matches only as review hints', async () => {
const a = await create();
const shared = await create();
const different = await create();
const malformed = await create();
const wrongSubject = await create();
await create({ guid: '456' });
await create({ game: 't6' });
const snapshot = (matchId: number, guid = '123') => ({
matchId,
match: { game: 't5', server: 'removed-server', started_at: clock },
entry: { type: 'message', message: { guid, message: 'Preserved evidence' } }
});
const add = (caseId: string, value: string) =>
handle
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(
randomUUID(),
caseId,
'Preserved evidence',
'Archived chat',
value,
'moderator',
'moderator',
clock
);
add(a.id, JSON.stringify(snapshot(12)));
add(shared.id, JSON.stringify(snapshot(12)));
add(different.id, JSON.stringify(snapshot(13)));
add(malformed.id, '{broken JSON');
add(malformed.id, JSON.stringify({ matchId: 12 }));
add(wrongSubject.id, JSON.stringify(snapshot(12, '456')));
const candidates = (await detail(a.id)).candidateCases!;
expect(candidates).toHaveLength(4);
expect(candidates.find((item) => item.id === shared.id)?.sharedMatch).toBe(true);
expect(
candidates.filter((item) => item.id !== shared.id).every((item) => !item.sharedMatch)
).toBe(true);
expect((await detail(a.id)).relatedCases).toEqual([]);
for (let index = 0; index < 30; index++) {
vi.setSystemTime(new Date(Date.parse(clock) + (index + 1) * 60_000));
await create({ title: `Later case ${index}` });
}
const recent = (await detail(a.id)).candidateCases!;
expect(recent).toHaveLength(25);
expect(recent[0].title).toBe('Later case 29');
expect(recent[24].title).toBe('Later case 5');
});
it('upgrades relationship storage with foreign keys, symmetric identity and active-only uniqueness', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 32));
const insertCase =
legacy.prepare(`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES(?,'t5','123','Existing case','open','former','Former staff',?,?)`);
insertCase.run('a', clock, clock);
insertCase.run('b', clock, clock);
const before = legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toEqual(before);
const insert = legacy.prepare(
'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)'
);
insert.run('first', 'a', 'b', 'same_incident', 'former', 'Former staff', clock);
expect(() =>
insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock)
).toThrow('UNIQUE');
expect(() =>
insert.run('reverse', 'b', 'a', 'same_incident', 'former', 'Former staff', clock)
).toThrow('CHECK');
expect(() =>
insert.run('self', 'a', 'a', 'appeal_of', 'former', 'Former staff', clock)
).toThrow('CHECK');
expect(() =>
insert.run('missing', 'a', 'missing', 'appeal_of', 'former', 'Former staff', clock)
).toThrow('FOREIGN KEY');
legacy
.prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?')
.run(clock, 'first');
insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock);
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});