feat: add automated community moderation

This commit is contained in:
JB
2026-07-27 16:09:56 -04:00
parent 6b76110b76
commit 27cbeca0ed
24 changed files with 3241 additions and 83 deletions
+153 -1
View File
@@ -171,7 +171,7 @@ if (typeof commentsDefinition?.sql === 'string' && commentsDefinition.sql.includ
author_id TEXT NOT NULL REFERENCES users(id),
parent_id TEXT REFERENCES comments(id),
body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'visible' CHECK(status IN ('visible', 'removed')),
status TEXT NOT NULL DEFAULT 'pending_review' CHECK(status IN ('pending_review', 'visible', 'removed')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
removed_at TEXT,
@@ -196,6 +196,158 @@ if (typeof commentsDefinition?.sql === 'string' && commentsDefinition.sql.includ
}
}
const normalizedCommentsDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'"
)).get();
if (
typeof normalizedCommentsDefinition?.sql === 'string'
&& !normalizedCommentsDefinition.sql.includes("'pending_review'")
) {
console.log('Adding pre-publication review status to community comments.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('ALTER TABLE comments RENAME TO comments_previous');
await database.exec(`CREATE TABLE comments (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
parent_id TEXT REFERENCES comments(id),
body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_review' CHECK(status IN ('pending_review', 'visible', 'removed')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
removed_at TEXT,
removed_by TEXT REFERENCES users(id),
removal_reason TEXT
)`);
await database.exec(`INSERT INTO comments(
id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at,
removed_at,removed_by,removal_reason
)
SELECT id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at,
removed_at,removed_by,removal_reason
FROM comments_previous`);
await database.exec('DROP TABLE comments_previous');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const moderationScansDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='content_moderation_scans'"
)).get();
const moderationScansMissingEvidence = (
typeof moderationScansDefinition?.sql === 'string'
&& (
!moderationScansDefinition.sql.includes('author_id TEXT')
|| !moderationScansDefinition.sql.includes('risk_level TEXT')
|| !moderationScansDefinition.sql.includes('reason TEXT')
)
);
if (
typeof moderationScansDefinition?.sql === 'string'
&& (
!moderationScansDefinition.sql.includes("'avatar'")
|| moderationScansMissingEvidence
)
) {
if (moderationScansMissingEvidence) {
const existingScans = await (
await database.prepare('SELECT COUNT(*) AS count FROM content_moderation_scans')
).get();
if (Number(existingScans?.count) > 0) {
throw new Error(
'Existing moderation scans lack required author, risk, or reason evidence and cannot be backfilled safely.'
);
}
}
console.log('Aligning automated community moderation scan evidence.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(`CREATE TABLE content_moderation_scans_expanded (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
content_id TEXT NOT NULL,
author_id TEXT NOT NULL REFERENCES users(id),
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
revision_id TEXT REFERENCES moderated_content_revisions(id),
policy_version INTEGER NOT NULL CHECK(policy_version>=1),
provider TEXT NOT NULL CHECK(length(trim(provider))>0),
model TEXT NOT NULL CHECK(length(trim(model))>0),
provider_request_id TEXT,
status TEXT NOT NULL CHECK(status IN ('approved','review','priority_review','error')),
risk_level TEXT NOT NULL CHECK(risk_level IN ('low','medium','high')),
reason TEXT NOT NULL CHECK(length(trim(reason))>0),
scores_json TEXT NOT NULL DEFAULT '{}',
flags_json TEXT NOT NULL DEFAULT '[]',
rule_matches_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
completed_at TEXT,
error_code TEXT,
error_message TEXT,
UNIQUE(content_type,content_id,content_hash,policy_version,provider,model),
CHECK(
(content_type IN ('comment','avatar') AND revision_id IS NULL)
OR (content_type IN ('profile_bio','guide') AND revision_id IS NOT NULL)
),
CHECK(
(status='error' AND error_code IS NOT NULL)
OR (status<>'error' AND error_code IS NULL AND error_message IS NULL)
)
)`);
if (!moderationScansMissingEvidence) {
await database.exec(`INSERT INTO content_moderation_scans_expanded(
id,content_type,content_id,author_id,content_hash,revision_id,policy_version,provider,model,
provider_request_id,status,risk_level,reason,scores_json,flags_json,rule_matches_json,
created_at,completed_at,error_code,error_message
)
SELECT id,content_type,content_id,author_id,content_hash,revision_id,policy_version,provider,model,
provider_request_id,status,risk_level,reason,scores_json,flags_json,rule_matches_json,
created_at,completed_at,error_code,error_message
FROM content_moderation_scans`);
}
await database.exec('DROP TABLE content_moderation_scans');
await database.exec('ALTER TABLE content_moderation_scans_expanded RENAME TO content_moderation_scans');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const communityModerationSettingsColumns = await (
await database.prepare("PRAGMA table_info('community_moderation_settings')")
).all();
if (
communityModerationSettingsColumns.length
&& !communityModerationSettingsColumns.some((column) => column.name === 'scan_avatars')
) {
console.log('Adding avatar scanning to community moderation settings.');
await database.exec(`ALTER TABLE community_moderation_settings
ADD COLUMN scan_avatars INTEGER NOT NULL DEFAULT 1 CHECK(scan_avatars IN (0,1))`);
}
const avatarReviewColumns = await (
await database.prepare("PRAGMA table_info('avatar_reviews')")
).all();
if (
avatarReviewColumns.length
&& !avatarReviewColumns.some((column) => column.name === 'review_origin')
) {
console.log('Adding review origin evidence to avatar oversight.');
await database.exec(`ALTER TABLE avatar_reviews
ADD COLUMN review_origin TEXT NOT NULL DEFAULT 'legacy'
CHECK(review_origin IN ('automated','user_report','legacy'))`);
}
// The project schema deliberately avoids semicolons inside string literals.
const statements = schema
.split(';')