feat: add automated community moderation
This commit is contained in:
+153
-1
@@ -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(';')
|
||||
|
||||
Reference in New Issue
Block a user