From 90b94911c557f87423c0bde9b07598c6f9e8be23 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 17 Oct 2026 19:52:50 -0400 Subject: [PATCH] Add related cases and duplicate review suggestions --- docs/guides/moderation.md | 10 + src/lib/moderation.ts | 24 ++ src/lib/server/db/migrations.ts | 8 +- src/lib/server/moderation/actions.ts | 16 + src/lib/server/moderation/cases.ts | 3 + src/lib/server/moderation/relationships.ts | 212 ++++++++++++ src/lib/server/moderation/schema.ts | 23 ++ .../moderation/CaseRelationshipDialog.svelte | 193 +++++++++++ .../moderation/CaseRelationships.svelte | 153 +++++++++ .../moderation/ExistingCaseNotice.svelte | 106 ++++++ .../moderation/ModerationDialog.svelte | 5 + src/lib/ui/moderation.ts | 21 ++ src/routes/moderation/cases/[id]/+page.svelte | 14 + tests/client/moderation-relationships.test.ts | 187 +++++++++++ .../moderation-case-relationships.test.ts | 315 ++++++++++++++++++ 15 files changed, 1289 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/moderation/relationships.ts create mode 100644 src/lib/ui/components/moderation/CaseRelationshipDialog.svelte create mode 100644 src/lib/ui/components/moderation/CaseRelationships.svelte create mode 100644 src/lib/ui/components/moderation/ExistingCaseNotice.svelte create mode 100644 tests/client/moderation-relationships.test.ts create mode 100644 tests/unit/moderation-case-relationships.test.ts diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md index 43cfd92..2a603db 100644 --- a/docs/guides/moderation.md +++ b/docs/guides/moderation.md @@ -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 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 and GUID and records the resulting restriction alongside the evidence. The same role, scope, and duration limits apply as elsewhere in Moderation. Applying a diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index bd0ba5d..12cf8e6 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -24,6 +24,13 @@ export const moderationCaseStatuses = [ ] as const; export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number]; 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 = { + same_incident: 'Same incident', + appeal_of: 'Appeal of', + duplicate_of: 'Duplicate of' +}; export const moderationCaseStatusLabels: Record = { open: 'Open', in_review: 'In review', @@ -173,11 +180,28 @@ export interface ModerationCaseDetail { availableActions?: ModerationCaseAction[]; assessment?: ModerationCaseAssessment | null; assessmentHistory?: ModerationCaseAssessment[]; + relatedCases?: ModerationCaseRelationship[]; + candidateCases?: ModerationCaseCandidate[]; events: ModerationEvent[]; coverage: ModerationCoverage[]; assignees: { id: string; username: 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 { id: string; type: 'restriction' | 'action'; diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index a50785c..7faea14 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -32,7 +32,8 @@ import { migrateModeration, migrateModerationCases, migrateModerationCaseActions, - migrateModerationCaseAssessments + migrateModerationCaseAssessments, + migrateModerationCaseRelationships } from '../moderation/schema.js'; import type { Migration } from './migration-runner.js'; @@ -185,6 +186,11 @@ export const migrations: readonly Migration[] = [ version: 32, name: 'moderation case assessment revision history', run: migrateModerationCaseAssessments + }, + { + version: 33, + name: 'moderation case relationships and duplicate review', + run: migrateModerationCaseRelationships } ]; diff --git a/src/lib/server/moderation/actions.ts b/src/lib/server/moderation/actions.ts index 00888dc..3c05618 100644 --- a/src/lib/server/moderation/actions.ts +++ b/src/lib/server/moderation/actions.ts @@ -3,6 +3,7 @@ import { db } from '../db/index.js'; import { archivedEvidenceSnapshots } from './evidence.js'; import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js'; import { assessmentDraft } from './assessments.js'; +import { linkCases, unlinkCases } from './relationships.js'; import { AuthError } from '../auth/index.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { @@ -473,6 +474,21 @@ export function mutateModeration(actorId: string, input: Record 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' || 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') { const row = caseRow(input.id); checkCaseRevision(row, input.revision); diff --git a/src/lib/server/moderation/cases.ts b/src/lib/server/moderation/cases.ts index 224943d..6bffc85 100644 --- a/src/lib/server/moderation/cases.ts +++ b/src/lib/server/moderation/cases.ts @@ -4,6 +4,7 @@ import { moderationActor, textField } from './core.js'; import { moderationOverview } from './overview.js'; import { caseActionLists } from './case-actions.js'; import { caseAssessmentHistory } from './assessments.js'; +import { relatedCases, candidateCases } from './relationships.js'; import type { ModerationCase, ModerationCaseDetail, @@ -86,6 +87,8 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas ...caseActionLists(caseId, row, overview.checkedAt), assessment: assessmentHistory[0] ?? null, assessmentHistory, + relatedCases: relatedCases(caseId), + candidateCases: candidateCases(row), events, coverage: overview.coverage, assignees, diff --git a/src/lib/server/moderation/relationships.ts b/src/lib/server/moderation/relationships.ts new file mode 100644 index 0000000..6d572f5 --- /dev/null +++ b/src/lib/server/moderation/relationships.ts @@ -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 & + Omit & { 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>(); + for (const snapshot of snapshots) { + const match = archivedMatch(snapshot.snapshot, subject); + if (match === null) continue; + const set = matches.get(snapshot.case_id) ?? new Set(); + set.add(match); + matches.set(snapshot.case_id, set); + } + const current = matches.get(subject.id) ?? new Set(); + return candidates.map((candidate) => ({ + ...candidate, + sharedMatch: [...(matches.get(candidate.id) ?? [])].some((match) => current.has(match)) + })); +} diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts index 782326a..73d925e 100644 --- a/src/lib/server/moderation/schema.ts +++ b/src/lib/server/moderation/schema.ts @@ -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); + `); +} diff --git a/src/lib/ui/components/moderation/CaseRelationshipDialog.svelte b/src/lib/ui/components/moderation/CaseRelationshipDialog.svelte new file mode 100644 index 0000000..1bfa1fc --- /dev/null +++ b/src/lib/ui/components/moderation/CaseRelationshipDialog.svelte @@ -0,0 +1,193 @@ + + + +
+
+ {#if relationship}

+ Remove the relationship with {relationship.case.title}. The + relationship history remains recorded. +

+ {:else} + {/if} +