Files
electric-cherry/src/lib/server/moderation/schema.ts
T
2026-10-17 21:30:43 -04:00

277 lines
15 KiB
TypeScript

import type Database from 'better-sqlite3';
import { moderationGuid, type ModerationGame } from '../../moderation.js';
export function migrateModeration(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_players (
game TEXT NOT NULL CHECK(game IN ('t5','t6')), guid TEXT NOT NULL,
name TEXT NOT NULL, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL,
PRIMARY KEY(game,guid)
);
CREATE TABLE IF NOT EXISTS moderation_aliases (
game TEXT NOT NULL, guid TEXT NOT NULL, name TEXT NOT NULL,
first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL,
PRIMARY KEY(game,guid,name)
);
CREATE INDEX IF NOT EXISTS moderation_alias_name ON moderation_aliases(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS moderation_policy (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL);
INSERT OR IGNORE INTO moderation_policy VALUES(1,1);
CREATE TABLE IF NOT EXISTS moderation_sanctions (
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')), guid TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('ban','mute')),
scope TEXT NOT NULL CHECK(scope IN ('server','game','fleet')),
server TEXT, server_created_at TEXT, reason TEXT NOT NULL, note TEXT NOT NULL,
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, expires_at TEXT, revoked_at TEXT,
revision INTEGER NOT NULL DEFAULT 1,
CHECK((scope='server' AND server IS NOT NULL AND server_created_at IS NOT NULL)
OR (scope!='server' AND server IS NULL AND server_created_at IS NULL))
);
CREATE INDEX IF NOT EXISTS moderation_sanction_subject ON moderation_sanctions(guid,game,revoked_at,expires_at);
CREATE TABLE IF NOT EXISTS moderation_actions (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT, server TEXT NOT NULL,
instance_id TEXT NOT NULL, generation INTEGER NOT NULL,
session TEXT NOT NULL, connection TEXT,
kind TEXT NOT NULL CHECK(kind IN ('warn','message','kick','announce')),
reason TEXT NOT NULL, actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued','delivered','confirmed','failed','expired','unknown')),
detail TEXT, delivered_at TEXT, finished_at TEXT
);
CREATE INDEX IF NOT EXISTS moderation_action_delivery ON moderation_actions(instance_id,generation,state,expires_at);
CREATE TABLE IF NOT EXISTS moderation_cases (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','resolved','dismissed')),
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS moderation_evidence (
id TEXT PRIMARY KEY, case_id TEXT NOT NULL REFERENCES moderation_cases(id),
text TEXT NOT NULL, source TEXT NOT NULL, snapshot TEXT,
actor_id TEXT NOT NULL, actor TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS moderation_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, game TEXT, guid TEXT,
actor_id TEXT, actor TEXT NOT NULL, action TEXT NOT NULL,
subject_id TEXT, detail TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS moderation_event_subject ON moderation_events(game,guid,id);
CREATE TABLE IF NOT EXISTS moderation_requests (
actor_id TEXT NOT NULL, request_key TEXT NOT NULL,
fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(actor_id,request_key)
);
CREATE TABLE IF NOT EXISTS moderation_credentials (
instance_id TEXT NOT NULL, generation INTEGER NOT NULL,
token TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL,
PRIMARY KEY(instance_id,generation)
);
CREATE TABLE IF NOT EXISTS moderation_runtimes (
instance_id TEXT PRIMARY KEY, generation INTEGER NOT NULL,
session TEXT NOT NULL, capabilities TEXT NOT NULL, players TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0, connected_at TEXT NOT NULL
);
`);
const player = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?)
ON CONFLICT(game,guid) DO UPDATE SET
name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END,
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
const alias = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
ON CONFLICT(game,guid,name) DO UPDATE SET
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
type Player = {
game: ModerationGame;
guid: string;
name: string;
first_seen_at: string;
last_seen_at: string;
};
const read = handle.prepare(`SELECT * FROM leaderboard_players
WHERE game IN ('t5','t6') AND (game,guid)>(?,?) ORDER BY game,guid LIMIT 1000`);
let cursor = ['', ''];
for (;;) {
// Finish each read before writing on this connection, without loading
// the full historical player directory into memory during an upgrade.
const rows = read.all(...cursor) as Player[];
if (!rows.length) break;
for (const row of rows) {
const values = [
row.game,
moderationGuid(row.game, row.guid),
row.name,
row.first_seen_at,
row.last_seen_at
];
player.run(...values);
alias.run(...values);
}
const last = rows[rows.length - 1];
cursor = [last.game, last.guid];
}
}
/** The migration runner disables foreign keys before opening this transaction. */
export function migrateModerationCases(handle: Database.Database): void {
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
if (!columns.some((column) => column.name === 'priority')) {
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The moderation case migration requires foreign keys to be disabled');
// Do not rename the existing parent: archived evidence must keep its
// original case reference through the rebuild.
handle.exec(`
CREATE TABLE moderation_cases_expanded (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','in_review','escalated','resolved','dismissed',
'warning_issued','restriction_applied','no_action','duplicate','appeal_upheld','appeal_denied')),
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL
);
INSERT INTO moderation_cases_expanded(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
SELECT id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision FROM moderation_cases;
DROP TABLE moderation_cases;
ALTER TABLE moderation_cases_expanded RENAME TO moderation_cases;
`);
}
if (
!(handle.pragma('table_info(moderation_sanctions)') as { name: string }[]).some(
(column) => column.name === 'case_id'
)
)
handle.exec(
'ALTER TABLE moderation_sanctions ADD COLUMN case_id TEXT REFERENCES moderation_cases(id) ON DELETE SET NULL'
);
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_cases_queue ON moderation_cases(status,priority,updated_at);
CREATE INDEX IF NOT EXISTS moderation_cases_assignee ON moderation_cases(assignee_id,updated_at);
CREATE INDEX IF NOT EXISTS moderation_sanctions_case ON moderation_sanctions(case_id);
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);
`);
}
export function migrateModerationCaseAssessments(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_assessments (
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
revision INTEGER NOT NULL CHECK(revision > 0),
data TEXT NOT NULL,
actor_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(case_id,revision)
);
`);
}
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);
`);
}
export function migrateModerationCaseFollowups(handle: Database.Database): void {
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
if (!columns.some((column) => column.name === 'review_at'))
handle.exec(`ALTER TABLE moderation_cases ADD COLUMN review_at TEXT;
ALTER TABLE moderation_cases ADD COLUMN review_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE moderation_cases ADD COLUMN review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL;`);
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_case_review_due ON moderation_cases(review_at) WHERE review_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS moderation_case_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
kind TEXT NOT NULL CHECK(kind IN ('evidence','assignment')),
created_at TEXT NOT NULL,
read_at TEXT
);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_unread ON moderation_case_notifications(user_id,read_at,id DESC);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_case ON moderation_case_notifications(user_id,case_id,read_at,id DESC);
`);
}
/** Foreign-key lookups must cover removed relationships and every notification recipient. */
export function migrateModerationCaseReferenceIndexes(handle: Database.Database): void {
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_case_review_sanction
ON moderation_cases(review_sanction_id);
CREATE INDEX IF NOT EXISTS moderation_case_relationship_source
ON moderation_case_relationships(case_id,removed_at);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_parent
ON moderation_case_notifications(case_id);
`);
}
/** Normalize lifecycle state while retaining every prior decision and child record. */
export function migrateModerationCaseStates(handle: Database.Database): void {
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The case status migration requires foreign keys to be disabled');
const objects = handle
.prepare(
`SELECT sql FROM sqlite_master
WHERE tbl_name='moderation_cases' AND type IN ('index','trigger') AND sql IS NOT NULL`
)
.all() as { sql: string }[];
// Capture old statuses even for imported cases without a corresponding decision event.
handle.exec(`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
SELECT game,guid,'Maxis','case.status_migrated',id,
json_object('from',status,'to',CASE WHEN status='escalated' THEN 'in_review' ELSE 'closed' END),
strftime('%Y-%m-%dT%H:%M:%fZ','now') FROM moderation_cases
WHERE status NOT IN ('open','in_review');
CREATE TABLE moderation_cases_state (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','in_review','closed')),
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL,
review_at TEXT, review_reason TEXT NOT NULL DEFAULT '',
review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL
);
INSERT INTO moderation_cases_state
SELECT id,game,guid,title,
CASE WHEN status IN ('open','in_review') THEN status
WHEN status='escalated' THEN 'in_review' ELSE 'closed' END,
actor_id,actor,created_at,updated_at,
revision+CASE WHEN status IN ('open','in_review') THEN 0 ELSE 1 END,
priority,assignee_id,review_at,review_reason,review_sanction_id FROM moderation_cases;
DROP TABLE moderation_cases;
ALTER TABLE moderation_cases_state RENAME TO moderation_cases;
`);
for (const object of objects) handle.exec(object.sql);
}