feat: add automated community moderation
This commit is contained in:
@@ -44,6 +44,116 @@ const parseAuditSnapshot = (value: unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
type QueueContentType = 'comment' | 'profile_bio' | 'guide';
|
||||
type QueueSource = 'scan' | 'report';
|
||||
|
||||
const contentPermission = (contentType: QueueContentType) =>
|
||||
contentType === 'profile_bio' ? PROFILE_PERMISSION : COMMENT_PERMISSION;
|
||||
|
||||
const parseJsonObject = (value: unknown) => {
|
||||
if (typeof value !== 'string') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const parseJsonArray = (value: unknown) => {
|
||||
if (typeof value !== 'string') return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const queueTargetHref = (
|
||||
contentType: QueueContentType,
|
||||
contentId: unknown,
|
||||
targetType: unknown,
|
||||
targetId: unknown,
|
||||
slug: unknown
|
||||
) => {
|
||||
if (contentType === 'profile_bio') return `/agents/${encodeURIComponent(String(contentId))}`;
|
||||
if (contentType === 'guide') {
|
||||
return slug
|
||||
? `/builds/${encodeURIComponent(String(slug))}`
|
||||
: `/builds/${encodeURIComponent(String(contentId))}`;
|
||||
}
|
||||
return targetHref(String(targetType), targetId, slug);
|
||||
};
|
||||
|
||||
const queueActionFailure = (status: number, message: string) => fail(status, {
|
||||
communityQueueError: message,
|
||||
communityResultToken: randomUUID()
|
||||
});
|
||||
|
||||
const allowedQueueTypes = (canModerateComments: boolean, canModerateProfiles: boolean) => [
|
||||
...(canModerateComments ? ['comment', 'guide'] as QueueContentType[] : []),
|
||||
...(canModerateProfiles ? ['profile_bio'] as QueueContentType[] : [])
|
||||
];
|
||||
|
||||
const assertQueuePermission = async (
|
||||
operator: NonNullable<Awaited<ReturnType<typeof getSessionUser>>>,
|
||||
contentType: QueueContentType
|
||||
) => {
|
||||
const granted = await getPermissions(operator);
|
||||
if (!has(granted, contentPermission(contentType))) {
|
||||
error(403, contentType === 'profile_bio'
|
||||
? 'Profile moderation permission required'
|
||||
: 'Comment moderation permission required');
|
||||
}
|
||||
};
|
||||
|
||||
const scanQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>, id: string) =>
|
||||
(await database.prepare(`SELECT scan.*,revision.body_markdown AS revision_body,
|
||||
comments.body_markdown AS comment_body,comments.status AS comment_status,
|
||||
comments.thread_id,thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
|
||||
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
|
||||
target_build.owner_id AS guide_owner_id,
|
||||
profile.bio_markdown AS current_bio,profile.bio_updated_at AS current_bio_updated_at,
|
||||
profile.updated_at AS profile_updated_at,COALESCE(profile.display_name,users.username) AS display_name
|
||||
FROM content_moderation_scans scan
|
||||
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
|
||||
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
|
||||
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
|
||||
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
LEFT JOIN builds target_build ON
|
||||
(scan.content_type='guide' AND target_build.id=scan.content_id)
|
||||
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
|
||||
AND target_build.id=thread.target_id)
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=scan.author_id
|
||||
LEFT JOIN users ON users.id=scan.author_id
|
||||
WHERE scan.id=? AND scan.content_type IN ('comment','profile_bio','guide')
|
||||
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL`)).get(id);
|
||||
|
||||
const reportQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>, id: string) =>
|
||||
(await database.prepare(`SELECT report.*,
|
||||
comments.body_markdown AS comment_body,comments.status AS comment_status,
|
||||
comments.author_id AS comment_author_id,comments.thread_id,
|
||||
thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
|
||||
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
|
||||
target_build.owner_id AS guide_owner_id,
|
||||
profile.bio_markdown AS current_bio,profile.is_public AS profile_is_public,
|
||||
COALESCE(moderation.bio_hidden,0) AS bio_hidden
|
||||
FROM user_content_reports report
|
||||
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
|
||||
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
LEFT JOIN builds target_build ON
|
||||
(report.content_type='guide' AND target_build.id=report.content_id)
|
||||
OR (report.content_type='comment' AND thread.target_type IN ('build','guide')
|
||||
AND target_build.id=thread.target_id)
|
||||
LEFT JOIN user_profiles profile ON report.content_type='profile_bio'
|
||||
AND profile.user_id=report.content_id
|
||||
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
||||
WHERE report.id=? AND report.status='pending'
|
||||
AND report.content_type IN ('comment','profile_bio','guide')`)).get(id);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const operator = await getSessionUser(cookies);
|
||||
if (!operator) error(401, 'Sign in required');
|
||||
@@ -53,18 +163,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
if (!canModerateComments && !canModerateProfiles) error(403, 'Community Oversight permission required');
|
||||
|
||||
const requestedTab = url.searchParams.get('tab');
|
||||
const tab = requestedTab === 'bios'
|
||||
const tab = requestedTab === 'queue'
|
||||
? 'queue'
|
||||
: requestedTab === 'bios'
|
||||
? 'bios'
|
||||
: requestedTab === 'comments'
|
||||
? 'comments'
|
||||
: canModerateComments ? 'comments' : 'bios';
|
||||
: 'queue';
|
||||
if (tab === 'comments' && !canModerateComments) error(403, 'Comment moderation permission required');
|
||||
if (tab === 'bios' && !canModerateProfiles) error(403, 'Profile moderation permission required');
|
||||
|
||||
const allowedStatuses = tab === 'comments' ? ['all', 'visible', 'removed'] : ['all', 'visible', 'hidden'];
|
||||
const allowedStatuses = tab === 'queue'
|
||||
? ['all', 'priority', 'automated', 'reported']
|
||||
: tab === 'comments'
|
||||
? ['all', 'visible', 'removed']
|
||||
: ['all', 'visible', 'hidden'];
|
||||
const status = allowedStatuses.includes(String(url.searchParams.get('status')))
|
||||
? String(url.searchParams.get('status'))
|
||||
: 'visible';
|
||||
: tab === 'queue' ? 'all' : 'visible';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const database = await getDatabase();
|
||||
const removedCutoff = new Date(Date.now() - REMOVED_RETENTION_DAYS * 86_400_000).toISOString();
|
||||
@@ -88,6 +204,207 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const commentSummary = Object.fromEntries(
|
||||
commentSummaryRows.map((row) => [String(row.status), Number(row.count)])
|
||||
);
|
||||
const queueTypes = allowedQueueTypes(canModerateComments, canModerateProfiles);
|
||||
const queueTypePlaceholders = queueTypes.map(() => '?').join(',');
|
||||
const [scanQueueSummary, reportQueueSummary] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN status='priority_review' THEN 1 ELSE 0 END) AS priority,
|
||||
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors
|
||||
FROM content_moderation_scans scan
|
||||
WHERE scan.content_type IN (${queueTypePlaceholders})
|
||||
AND scan.status IN ('review','priority_review','error')
|
||||
AND NOT EXISTS(
|
||||
SELECT 1 FROM content_moderation_reviews review WHERE review.scan_id=scan.id
|
||||
)`)).get(...queueTypes),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total
|
||||
FROM user_content_reports
|
||||
WHERE content_type IN (${queueTypePlaceholders}) AND status='pending'`)).get(...queueTypes)
|
||||
]);
|
||||
const queueSummary = {
|
||||
automated: Number(scanQueueSummary?.total ?? 0),
|
||||
reported: Number(reportQueueSummary?.total ?? 0),
|
||||
priority: Number(scanQueueSummary?.priority ?? 0) + Number(scanQueueSummary?.errors ?? 0)
|
||||
};
|
||||
const summary = {
|
||||
queue: { ...queueSummary, total: queueSummary.automated + queueSummary.reported },
|
||||
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
|
||||
bios: { visible: visibleBios, hidden: hiddenBios }
|
||||
};
|
||||
|
||||
if (tab === 'queue') {
|
||||
const scanQueue = `SELECT
|
||||
scan.id AS queue_id,'scan' AS item_source,scan.content_type,scan.content_id,
|
||||
scan.author_id,COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
|
||||
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
|
||||
author_avatar.updated_at AS avatar_updated_at,
|
||||
COALESCE(revision.body_markdown,comments.body_markdown,target_build.guide_body,'') AS body_markdown,
|
||||
scan.created_at AS queued_at,scan.status AS scan_status,scan.risk_level,scan.reason,
|
||||
NULL AS category,scan.provider,scan.model,scan.provider_request_id,
|
||||
scan.scores_json,scan.flags_json,scan.rule_matches_json,NULL AS reporter_name,
|
||||
NULL AS details,thread.target_type,thread.target_id,
|
||||
CASE
|
||||
WHEN scan.content_type='profile_bio'
|
||||
THEN COALESCE(author_profile.display_name,author.username,'Agent profile')
|
||||
WHEN scan.content_type='guide'
|
||||
THEN COALESCE(target_build.title,'Build guide')
|
||||
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
|
||||
END AS target_title,
|
||||
target_build.slug
|
||||
FROM content_moderation_scans scan
|
||||
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
|
||||
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
|
||||
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
|
||||
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
LEFT JOIN builds target_build ON
|
||||
(scan.content_type='guide' AND target_build.id=scan.content_id)
|
||||
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
|
||||
AND target_build.id=thread.target_id)
|
||||
LEFT JOIN users target_user ON scan.content_type='comment'
|
||||
AND thread.target_type='profile' AND target_user.id=thread.target_id
|
||||
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id
|
||||
LEFT JOIN users author ON author.id=scan.author_id
|
||||
LEFT JOIN user_profiles author_profile ON author_profile.user_id=author.id
|
||||
LEFT JOIN user_avatars author_avatar ON author_avatar.user_id=author.id
|
||||
WHERE scan.content_type IN (${queueTypePlaceholders})
|
||||
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL`;
|
||||
const reportQueue = `SELECT
|
||||
report.id AS queue_id,'report' AS item_source,report.content_type,report.content_id,
|
||||
COALESCE(comments.author_id,profile.user_id,target_build.owner_id) AS author_id,
|
||||
COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
|
||||
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
|
||||
author_avatar.updated_at AS avatar_updated_at,
|
||||
COALESCE(comments.body_markdown,profile.bio_markdown,target_build.guide_body,'') AS body_markdown,
|
||||
report.created_at AS queued_at,NULL AS scan_status,'reported' AS risk_level,
|
||||
COALESCE(report.details,'A community member requested Oversight review.') AS reason,
|
||||
report.category,NULL AS provider,NULL AS model,NULL AS provider_request_id,
|
||||
'{}' AS scores_json,'{}' AS flags_json,'[]' AS rule_matches_json,
|
||||
COALESCE(reporter_profile.display_name,reporter.username,'Community member') AS reporter_name,
|
||||
report.details,thread.target_type,thread.target_id,
|
||||
CASE
|
||||
WHEN report.content_type='profile_bio'
|
||||
THEN COALESCE(profile.display_name,author.username,'Agent profile')
|
||||
WHEN report.content_type='guide'
|
||||
THEN COALESCE(target_build.title,'Build guide')
|
||||
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
|
||||
END AS target_title,
|
||||
target_build.slug
|
||||
FROM user_content_reports report
|
||||
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
|
||||
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
LEFT JOIN user_profiles profile ON report.content_type='profile_bio'
|
||||
AND profile.user_id=report.content_id
|
||||
LEFT JOIN builds target_build ON
|
||||
(report.content_type='guide' AND target_build.id=report.content_id)
|
||||
OR (report.content_type='comment' AND thread.target_type IN ('build','guide')
|
||||
AND target_build.id=thread.target_id)
|
||||
LEFT JOIN users target_user ON report.content_type='comment'
|
||||
AND thread.target_type='profile' AND target_user.id=thread.target_id
|
||||
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id
|
||||
LEFT JOIN users author ON author.id=COALESCE(comments.author_id,profile.user_id,target_build.owner_id)
|
||||
LEFT JOIN user_profiles author_profile ON author_profile.user_id=author.id
|
||||
LEFT JOIN user_avatars author_avatar ON author_avatar.user_id=author.id
|
||||
LEFT JOIN users reporter ON reporter.id=report.reporter_id
|
||||
LEFT JOIN user_profiles reporter_profile ON reporter_profile.user_id=reporter.id
|
||||
WHERE report.content_type IN (${queueTypePlaceholders}) AND report.status='pending'`;
|
||||
const union = status === 'automated'
|
||||
? scanQueue
|
||||
: status === 'reported'
|
||||
? reportQueue
|
||||
: `${scanQueue} UNION ALL ${reportQueue}`;
|
||||
const args: unknown[] = status === 'automated' || status === 'reported'
|
||||
? [...queueTypes]
|
||||
: [...queueTypes, ...queueTypes];
|
||||
const conditions: string[] = [];
|
||||
if (status === 'priority') {
|
||||
conditions.push("item_source='scan' AND scan_status IN ('priority_review','error')");
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`(body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR target_title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR COALESCE(reason,'') LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR COALESCE(category,'') LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
|
||||
args.push(pattern, pattern, pattern, pattern, pattern);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const total = Number((await (await database.prepare(
|
||||
`WITH queue_items AS (${union}) SELECT COUNT(*) AS count FROM queue_items ${where}`
|
||||
)).get(...args))?.count ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const rows = await (await database.prepare(`WITH queue_items AS (${union})
|
||||
SELECT * FROM queue_items ${where}
|
||||
ORDER BY CASE
|
||||
WHEN scan_status='priority_review' THEN 0
|
||||
WHEN scan_status='error' THEN 1
|
||||
WHEN item_source='report' THEN 2
|
||||
ELSE 3
|
||||
END,queued_at DESC LIMIT ? OFFSET ?`))
|
||||
.all(...args, pagination.perPage, pagination.offset);
|
||||
return {
|
||||
tab,
|
||||
status,
|
||||
search,
|
||||
summary,
|
||||
items: rows.map((row) => {
|
||||
const contentType = String(row.content_type) as QueueContentType;
|
||||
const scores = parseJsonObject(row.scores_json);
|
||||
const flags = parseJsonObject(row.flags_json);
|
||||
return {
|
||||
id: String(row.queue_id),
|
||||
source: String(row.item_source) as QueueSource,
|
||||
contentType,
|
||||
contentId: String(row.content_id),
|
||||
bodyHtml: renderUserMarkdown(row.body_markdown),
|
||||
queuedAt: String(row.queued_at),
|
||||
scanStatus: row.scan_status ? String(row.scan_status) : null,
|
||||
riskLevel: String(row.risk_level),
|
||||
reason: String(row.reason),
|
||||
category: row.category ? String(row.category) : null,
|
||||
provider: row.provider ? String(row.provider) : null,
|
||||
model: row.model ? String(row.model) : null,
|
||||
providerRequestId: row.provider_request_id ? String(row.provider_request_id) : null,
|
||||
reporterName: row.reporter_name ? String(row.reporter_name) : null,
|
||||
scores: Object.entries(scores)
|
||||
.map(([category, score]) => ({ category, score: Number(score) }))
|
||||
.filter((entry) => Number.isFinite(entry.score) && entry.score > 0)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 5),
|
||||
flags: Object.entries(flags)
|
||||
.filter(([, flagged]) => Boolean(flagged))
|
||||
.map(([category]) => category),
|
||||
ruleMatches: parseJsonArray(row.rule_matches_json).flatMap((match) => {
|
||||
if (!match || typeof match !== 'object' || Array.isArray(match)) return [];
|
||||
const record = match as Record<string, unknown>;
|
||||
return [{
|
||||
label: String(record.label ?? record.kind ?? 'LOCAL RULE'),
|
||||
excerpt: String(record.excerpt ?? '')
|
||||
}];
|
||||
}),
|
||||
targetType: contentType === 'comment' ? String(row.target_type ?? 'content') : contentType,
|
||||
targetTitle: String(row.target_title),
|
||||
targetHref: queueTargetHref(
|
||||
contentType,
|
||||
row.content_id,
|
||||
row.target_type,
|
||||
row.target_id,
|
||||
row.slug
|
||||
),
|
||||
author: {
|
||||
id: String(row.author_id ?? ''),
|
||||
displayName: String(row.display_name),
|
||||
avatarUrl: avatarUrl(
|
||||
row.avatar_source,
|
||||
row.avatar_value,
|
||||
String(row.author_id ?? ''),
|
||||
row.avatar_updated_at
|
||||
)
|
||||
}
|
||||
};
|
||||
}),
|
||||
pagination,
|
||||
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
|
||||
};
|
||||
}
|
||||
|
||||
if (tab === 'comments') {
|
||||
const sort = getRegistrySort(url, {
|
||||
@@ -150,10 +467,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
tab,
|
||||
status,
|
||||
search,
|
||||
summary: {
|
||||
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
|
||||
bios: { visible: visibleBios, hidden: hiddenBios }
|
||||
},
|
||||
summary,
|
||||
items: rows.map((row) => ({
|
||||
id: String(row.id),
|
||||
bodyHtml: renderUserMarkdown(row.body_markdown),
|
||||
@@ -221,10 +535,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
tab,
|
||||
status,
|
||||
search,
|
||||
summary: {
|
||||
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
|
||||
bios: { visible: visibleBios, hidden: hiddenBios }
|
||||
},
|
||||
summary,
|
||||
items: rows.map((row) => ({
|
||||
id: String(row.user_id),
|
||||
bodyHtml: renderUserMarkdown(row.bio_markdown),
|
||||
@@ -244,6 +555,364 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
approveQueue: async ({ cookies, request }) => {
|
||||
const operator = await getSessionUser(cookies);
|
||||
if (!operator) return queueActionFailure(401, 'Sign in to review community content.');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const source = String(form.get('source') ?? '') as QueueSource;
|
||||
const reason = requiredReason(form.get('reason'));
|
||||
if (!id || !['scan', 'report'].includes(source) || !reason) {
|
||||
return queueActionFailure(400, 'Choose a queue item and provide a brief approval reason.');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
|
||||
if (source === 'report') {
|
||||
const report = await reportQueueRecord(database, id);
|
||||
if (!report) return queueActionFailure(404, 'That user report is no longer pending.');
|
||||
const contentType = String(report.content_type) as QueueContentType;
|
||||
await assertQueuePermission(operator, contentType);
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = await (await database.prepare(`UPDATE user_content_reports
|
||||
SET status='dismissed',reviewed_at=?,reviewed_by=?,resolution_reason=?
|
||||
WHERE id=? AND status='pending'`)).run(now, operator.id, reason, id);
|
||||
if (!result.changes) {
|
||||
await database.exec('ROLLBACK');
|
||||
return queueActionFailure(409, 'That report changed before it could be dismissed.');
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'community_moderation',
|
||||
action: 'content_report.dismissed',
|
||||
entityId: id,
|
||||
before: {
|
||||
status: 'pending',
|
||||
contentType,
|
||||
contentId: String(report.content_id),
|
||||
category: String(report.category),
|
||||
reporterId: String(report.reporter_id)
|
||||
},
|
||||
after: {
|
||||
status: 'dismissed',
|
||||
reason,
|
||||
reviewedAt: now,
|
||||
reviewedBy: operator.id
|
||||
}
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (cause) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw cause;
|
||||
}
|
||||
return { communitySaved: true, communityResultToken: randomUUID() };
|
||||
}
|
||||
|
||||
const scan = await scanQueueRecord(database, id);
|
||||
if (!scan) return queueActionFailure(404, 'That automated review is no longer pending.');
|
||||
const contentType = String(scan.content_type) as QueueContentType;
|
||||
await assertQueuePermission(operator, contentType);
|
||||
const body = contentType === 'comment' ? String(scan.comment_body ?? '') : String(scan.revision_body ?? '');
|
||||
if (!body.trim()) return queueActionFailure(409, 'The queued content is no longer available.');
|
||||
const now = new Date().toISOString();
|
||||
const reviewId = randomUUID();
|
||||
let targetOwnerId: string | null = null;
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
|
||||
(id,scan_id,reviewer_id,decision,reason,created_at)
|
||||
SELECT ?,?,?,'approved',?,?
|
||||
WHERE NOT EXISTS(SELECT 1 FROM content_moderation_reviews WHERE scan_id=?)`))
|
||||
.run(reviewId, id, operator.id, reason, now, id);
|
||||
if (!claim.changes) {
|
||||
await database.exec('ROLLBACK');
|
||||
return queueActionFailure(409, 'Another reviewer already handled this scan.');
|
||||
}
|
||||
if (contentType === 'comment') {
|
||||
const result = await (await database.prepare(`UPDATE comments
|
||||
SET status='visible',removed_at=NULL,removed_by=NULL,removal_reason=NULL,updated_at=?
|
||||
WHERE id=? AND status='pending_review'`)).run(now, scan.content_id);
|
||||
if (!result.changes) throw new Error('The pending comment changed before approval.');
|
||||
targetOwnerId = String(scan.target_type) === 'profile'
|
||||
? String(scan.target_id)
|
||||
: scan.guide_owner_id ? String(scan.guide_owner_id) : null;
|
||||
} else if (contentType === 'profile_bio') {
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,1,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
bio_markdown=excluded.bio_markdown,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`))
|
||||
.run(scan.content_id, String(scan.display_name ?? 'Agent'), body, now, now);
|
||||
await (await database.prepare(`UPDATE profile_moderation SET bio_hidden=0,
|
||||
reason=?,moderated_at=?,moderated_by=? WHERE user_id=?`))
|
||||
.run(reason, now, operator.id, scan.content_id);
|
||||
} else {
|
||||
const result = await (await database.prepare(`UPDATE builds
|
||||
SET guide_body=?,updated_at=? WHERE id=? AND owner_id=?`))
|
||||
.run(body, now, scan.content_id, scan.author_id);
|
||||
if (!result.changes) throw new Error('The queued guide changed before approval.');
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'community_moderation',
|
||||
action: `${contentType}.review_approved`,
|
||||
entityId: String(scan.content_id),
|
||||
before: {
|
||||
scanId: id,
|
||||
status: String(scan.status),
|
||||
riskLevel: String(scan.risk_level),
|
||||
reason: String(scan.reason),
|
||||
policyVersion: Number(scan.policy_version),
|
||||
provider: String(scan.provider),
|
||||
model: String(scan.model),
|
||||
currentStatus: contentType === 'comment'
|
||||
? String(scan.comment_status)
|
||||
: contentType === 'profile_bio' ? 'revision_pending' : 'revision_pending'
|
||||
},
|
||||
after: {
|
||||
scanId: id,
|
||||
reviewId,
|
||||
decision: 'approved',
|
||||
reason,
|
||||
reviewedAt: now,
|
||||
reviewedBy: operator.id,
|
||||
published: true
|
||||
}
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (cause) {
|
||||
await database.exec('ROLLBACK');
|
||||
if (cause instanceof Error && cause.message.includes('changed before approval')) {
|
||||
return queueActionFailure(409, cause.message);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
|
||||
const authorId = String(scan.author_id);
|
||||
const link = queueTargetHref(
|
||||
contentType,
|
||||
scan.content_id,
|
||||
scan.target_type,
|
||||
scan.target_id,
|
||||
scan.slug
|
||||
);
|
||||
await createNotification({
|
||||
userId: authorId,
|
||||
category: 'isac',
|
||||
source: 'ISAC COMMUNITY OVERSIGHT',
|
||||
title: contentType === 'comment'
|
||||
? 'Comment approved'
|
||||
: contentType === 'profile_bio' ? 'Agent bio approved' : 'Guide approved',
|
||||
body: `Community Oversight approved your ${contentType === 'profile_bio' ? 'Agent bio' : contentType}. Reason: ${reason}`,
|
||||
link,
|
||||
actions: [{ label: 'VIEW CONTENT', href: link }],
|
||||
mandatory: true
|
||||
});
|
||||
if (contentType === 'comment' && targetOwnerId && targetOwnerId !== authorId) {
|
||||
await createNotification({
|
||||
userId: targetOwnerId,
|
||||
category: 'comments',
|
||||
source: 'agent',
|
||||
title: 'New comment',
|
||||
body: `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
|
||||
link,
|
||||
actions: [{ label: 'VIEW COMMENT', href: link }]
|
||||
});
|
||||
}
|
||||
return { communitySaved: true, communityResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
rejectQueue: async ({ cookies, request }) => {
|
||||
const operator = await getSessionUser(cookies);
|
||||
if (!operator) return queueActionFailure(401, 'Sign in to review community content.');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const source = String(form.get('source') ?? '') as QueueSource;
|
||||
const reason = requiredReason(form.get('reason'));
|
||||
if (!id || !['scan', 'report'].includes(source) || !reason) {
|
||||
return queueActionFailure(400, 'Choose a queue item and provide a brief rejection reason.');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
|
||||
if (source === 'report') {
|
||||
const report = await reportQueueRecord(database, id);
|
||||
if (!report) return queueActionFailure(404, 'That user report is no longer pending.');
|
||||
const contentType = String(report.content_type) as QueueContentType;
|
||||
await assertQueuePermission(operator, contentType);
|
||||
const authorId = contentType === 'comment'
|
||||
? String(report.comment_author_id ?? '')
|
||||
: contentType === 'profile_bio'
|
||||
? String(report.content_id)
|
||||
: String(report.guide_owner_id ?? '');
|
||||
if (!authorId) return queueActionFailure(409, 'The reported content author is no longer available.');
|
||||
const now = new Date().toISOString();
|
||||
const before = {
|
||||
reportId: id,
|
||||
contentType,
|
||||
contentId: String(report.content_id),
|
||||
reportCategory: String(report.category),
|
||||
reporterId: String(report.reporter_id),
|
||||
contentStatus: contentType === 'comment'
|
||||
? String(report.comment_status ?? 'missing')
|
||||
: contentType === 'profile_bio'
|
||||
? Boolean(report.bio_hidden) ? 'hidden' : 'visible'
|
||||
: String(report.current_guide_visibility ?? 'missing')
|
||||
};
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (contentType === 'comment') {
|
||||
const result = await (await database.prepare(`UPDATE comments SET status='removed',
|
||||
removed_at=?,removed_by=?,removal_reason=?,updated_at=?
|
||||
WHERE id=? AND status='visible'`))
|
||||
.run(now, operator.id, reason, now, report.content_id);
|
||||
if (!result.changes) throw new Error('The reported comment is no longer visible.');
|
||||
} else if (contentType === 'profile_bio') {
|
||||
if (!String(report.current_bio ?? '').trim() || Boolean(report.bio_hidden)) {
|
||||
throw new Error('The reported biography is no longer visible.');
|
||||
}
|
||||
await (await database.prepare(`INSERT INTO profile_moderation
|
||||
(user_id,bio_hidden,reason,moderated_at,moderated_by) VALUES(?,1,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET bio_hidden=1,reason=excluded.reason,
|
||||
moderated_at=excluded.moderated_at,moderated_by=excluded.moderated_by`))
|
||||
.run(report.content_id, reason, now, operator.id);
|
||||
} else {
|
||||
if (!String(report.current_guide_body ?? '').trim() || report.current_guide_visibility === 'private') {
|
||||
throw new Error('The reported guide is no longer publicly available.');
|
||||
}
|
||||
const result = await (await database.prepare(`UPDATE builds
|
||||
SET visibility='private',updated_at=? WHERE id=? AND visibility<>'private'`))
|
||||
.run(now, report.content_id);
|
||||
if (!result.changes) throw new Error('The reported guide changed before it could be hidden.');
|
||||
}
|
||||
const resolved = await (await database.prepare(`UPDATE user_content_reports
|
||||
SET status='resolved',reviewed_at=?,reviewed_by=?,resolution_reason=?
|
||||
WHERE content_type=? AND content_id=? AND status='pending'`))
|
||||
.run(now, operator.id, reason, contentType, report.content_id);
|
||||
if (!resolved.changes) throw new Error('The pending reports changed before resolution.');
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'community_moderation',
|
||||
action: contentType === 'comment'
|
||||
? 'comment.report_removed'
|
||||
: contentType === 'profile_bio' ? 'profile_bio.report_hidden' : 'guide.report_hidden',
|
||||
entityId: String(report.content_id),
|
||||
before,
|
||||
after: {
|
||||
status: contentType === 'comment' ? 'removed' : 'hidden',
|
||||
reason,
|
||||
reviewedAt: now,
|
||||
reviewedBy: operator.id,
|
||||
resolvedReports: Number(resolved.changes)
|
||||
}
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (cause) {
|
||||
await database.exec('ROLLBACK');
|
||||
if (cause instanceof Error && (
|
||||
cause.message.includes('no longer')
|
||||
|| cause.message.includes('changed before')
|
||||
)) return queueActionFailure(409, cause.message);
|
||||
throw cause;
|
||||
}
|
||||
const link = queueTargetHref(
|
||||
contentType,
|
||||
report.content_id,
|
||||
report.target_type,
|
||||
report.target_id,
|
||||
report.slug
|
||||
);
|
||||
await createNotification({
|
||||
userId: authorId,
|
||||
category: 'isac',
|
||||
source: 'ISAC COMMUNITY OVERSIGHT',
|
||||
title: contentType === 'comment'
|
||||
? 'Comment removed'
|
||||
: contentType === 'profile_bio' ? 'Agent bio hidden' : 'Guide hidden',
|
||||
body: `Community Oversight ${contentType === 'comment' ? 'removed' : 'hid'} your ${contentType === 'profile_bio' ? 'Agent bio' : contentType}. Reason: ${reason}`,
|
||||
link: contentType === 'profile_bio' ? '/account' : link,
|
||||
actions: [{
|
||||
label: 'REVIEW CONTENT',
|
||||
href: contentType === 'profile_bio' ? '/account' : link
|
||||
}],
|
||||
mandatory: true
|
||||
});
|
||||
return { communitySaved: true, communityResultToken: randomUUID() };
|
||||
}
|
||||
|
||||
const scan = await scanQueueRecord(database, id);
|
||||
if (!scan) return queueActionFailure(404, 'That automated review is no longer pending.');
|
||||
const contentType = String(scan.content_type) as QueueContentType;
|
||||
await assertQueuePermission(operator, contentType);
|
||||
const now = new Date().toISOString();
|
||||
const reviewId = randomUUID();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
|
||||
(id,scan_id,reviewer_id,decision,reason,created_at)
|
||||
SELECT ?,?,?,'rejected',?,?
|
||||
WHERE NOT EXISTS(SELECT 1 FROM content_moderation_reviews WHERE scan_id=?)`))
|
||||
.run(reviewId, id, operator.id, reason, now, id);
|
||||
if (!claim.changes) {
|
||||
await database.exec('ROLLBACK');
|
||||
return queueActionFailure(409, 'Another reviewer already handled this scan.');
|
||||
}
|
||||
if (contentType === 'comment') {
|
||||
const result = await (await database.prepare(`UPDATE comments SET status='removed',
|
||||
removed_at=?,removed_by=?,removal_reason=?,updated_at=?
|
||||
WHERE id=? AND status='pending_review'`))
|
||||
.run(now, operator.id, reason, now, scan.content_id);
|
||||
if (!result.changes) throw new Error('The pending comment changed before rejection.');
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'community_moderation',
|
||||
action: `${contentType}.review_rejected`,
|
||||
entityId: String(scan.content_id),
|
||||
before: {
|
||||
scanId: id,
|
||||
status: String(scan.status),
|
||||
riskLevel: String(scan.risk_level),
|
||||
reason: String(scan.reason),
|
||||
policyVersion: Number(scan.policy_version),
|
||||
provider: String(scan.provider),
|
||||
model: String(scan.model)
|
||||
},
|
||||
after: {
|
||||
scanId: id,
|
||||
reviewId,
|
||||
decision: 'rejected',
|
||||
reason,
|
||||
reviewedAt: now,
|
||||
reviewedBy: operator.id,
|
||||
published: false
|
||||
}
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (cause) {
|
||||
await database.exec('ROLLBACK');
|
||||
if (cause instanceof Error && cause.message.includes('changed before rejection')) {
|
||||
return queueActionFailure(409, cause.message);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
await createNotification({
|
||||
userId: String(scan.author_id),
|
||||
category: 'isac',
|
||||
source: 'ISAC COMMUNITY OVERSIGHT',
|
||||
title: contentType === 'comment'
|
||||
? 'Comment rejected'
|
||||
: contentType === 'profile_bio' ? 'Agent bio revision rejected' : 'Guide revision rejected',
|
||||
body: `Community Oversight rejected your ${contentType === 'profile_bio' ? 'Agent bio revision' : contentType}. Reason: ${reason}`,
|
||||
link: '/account',
|
||||
actions: [{ label: 'REVIEW ACCOUNT', href: '/account' }],
|
||||
mandatory: true
|
||||
});
|
||||
return { communitySaved: true, communityResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
removeComment: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
|
||||
const form = await request.formData();
|
||||
|
||||
Reference in New Issue
Block a user