Add related cases and duplicate review suggestions
This commit is contained in:
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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<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' || 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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user