277 lines
15 KiB
TypeScript
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);
|
|
}
|