1160 lines
50 KiB
TypeScript
1160 lines
50 KiB
TypeScript
import { avatarUrl } from '$lib/avatar';
|
|
import { recordIsacAudit } from '$lib/server/audit';
|
|
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
|
import { getSessionUser } from '$lib/server/auth/session';
|
|
import { getDatabase } from '$lib/server/database';
|
|
import { renderUserMarkdown } from '$lib/markdown';
|
|
import { createNotification } from '$lib/server/notifications';
|
|
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
|
import { error, fail } from '@sveltejs/kit';
|
|
import { randomUUID } from 'node:crypto';
|
|
import type { Actions, PageServerLoad } from './$types';
|
|
import { agentProfileHref } from '$lib/agents';
|
|
|
|
const COMMENT_PERMISSION = 'community.comments.moderate';
|
|
const PROFILE_PERMISSION = 'community.profiles.moderate';
|
|
const REMOVED_RETENTION_DAYS = 30;
|
|
|
|
const has = (permissions: string[], permission: string) =>
|
|
permissions.includes('*') || permissions.includes(permission);
|
|
|
|
const actionFailure = (status: number, message: string) => fail(status, {
|
|
communityError: message,
|
|
communityResultToken: randomUUID()
|
|
});
|
|
|
|
const requiredReason = (value: FormDataEntryValue | null) => {
|
|
const reason = String(value ?? '').normalize('NFKC').trim().slice(0, 500);
|
|
return reason.length >= 3 ? reason : null;
|
|
};
|
|
|
|
const targetHref = (targetType: string, targetId: unknown, slug: unknown, publicSlug?: unknown) =>
|
|
targetType === 'profile'
|
|
? agentProfileHref({
|
|
id: String(targetId),
|
|
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
|
|
}, 'profile-comments')
|
|
: `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
|
|
|
|
const parseAuditSnapshot = (value: unknown) => {
|
|
if (typeof value !== 'string') return null;
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
? parsed as Record<string, unknown>
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
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,
|
|
publicSlug?: unknown
|
|
) => {
|
|
if (contentType === 'profile_bio') {
|
|
return agentProfileHref({
|
|
id: String(contentId),
|
|
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
|
|
});
|
|
}
|
|
if (contentType === 'guide') {
|
|
return slug
|
|
? `/builds/${encodeURIComponent(String(slug))}`
|
|
: `/builds/${encodeURIComponent(String(contentId))}`;
|
|
}
|
|
return targetHref(String(targetType), targetId, slug, publicSlug);
|
|
};
|
|
|
|
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');
|
|
const granted = await getPermissions(operator);
|
|
const canModerateComments = has(granted, COMMENT_PERMISSION);
|
|
const canModerateProfiles = has(granted, PROFILE_PERMISSION);
|
|
if (!canModerateComments && !canModerateProfiles) error(403, 'Community Oversight permission required');
|
|
|
|
const requestedTab = url.searchParams.get('tab');
|
|
const tab = requestedTab === 'queue'
|
|
? 'queue'
|
|
: requestedTab === 'bios'
|
|
? 'bios'
|
|
: requestedTab === 'comments'
|
|
? 'comments'
|
|
: 'queue';
|
|
if (tab === 'comments' && !canModerateComments) error(403, 'Comment moderation permission required');
|
|
if (tab === 'bios' && !canModerateProfiles) error(403, 'Profile moderation permission required');
|
|
|
|
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'))
|
|
: 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();
|
|
|
|
const commentSummaryRows = canModerateComments
|
|
? await (await database.prepare(`SELECT status,COUNT(*) AS count FROM comments
|
|
WHERE status='visible' OR (status='removed' AND removed_at>=?)
|
|
GROUP BY status`)).all(removedCutoff)
|
|
: [];
|
|
const visibleBios = canModerateProfiles
|
|
? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM user_profiles profile
|
|
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
|
WHERE profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0
|
|
AND length(trim(profile.bio_markdown))>0`)).get())?.count ?? 0)
|
|
: 0;
|
|
const hiddenBios = canModerateProfiles
|
|
? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM user_profiles profile
|
|
JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
|
WHERE moderation.bio_hidden=1 AND length(trim(profile.bio_markdown))>0`)).get())?.count ?? 0)
|
|
: 0;
|
|
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_profile.public_slug AS author_public_slug,
|
|
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,COALESCE(target_profile.public_slug,author_profile.public_slug) AS target_public_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_profile.public_slug AS author_public_slug,
|
|
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,COALESCE(target_profile.public_slug,profile.public_slug) AS target_public_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,
|
|
row.target_public_slug
|
|
),
|
|
author: {
|
|
id: String(row.author_id ?? ''),
|
|
displayName: String(row.display_name),
|
|
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
|
|
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, {
|
|
created: {
|
|
asc: 'comments.created_at ASC',
|
|
desc: 'comments.created_at DESC',
|
|
defaultDirection: 'desc'
|
|
},
|
|
updated: {
|
|
asc: 'comments.updated_at ASC',
|
|
desc: 'comments.updated_at DESC',
|
|
defaultDirection: 'desc'
|
|
},
|
|
author: {
|
|
asc: 'display_name COLLATE NOCASE ASC',
|
|
desc: 'display_name COLLATE NOCASE DESC'
|
|
}
|
|
}, 'created', 'desc');
|
|
const conditions: string[] = [];
|
|
const args: unknown[] = [];
|
|
if (status === 'all') {
|
|
conditions.push("(comments.status='visible' OR (comments.status='removed' AND comments.removed_at>=?))");
|
|
args.push(removedCutoff);
|
|
} else {
|
|
conditions.push('comments.status=?');
|
|
args.push(status);
|
|
}
|
|
if (status === 'removed') {
|
|
conditions.push('comments.removed_at>=?');
|
|
args.push(removedCutoff);
|
|
}
|
|
if (search) {
|
|
conditions.push(`(comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
OR COALESCE(profile.display_name,users.username,'Deleted Agent') LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
OR COALESCE(builds.title,target_profile.display_name,target_user.username,'') LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
|
|
args.push(pattern, pattern, pattern);
|
|
}
|
|
const joins = `FROM comments
|
|
JOIN comment_threads thread ON thread.id=comments.thread_id
|
|
LEFT JOIN users ON users.id=comments.author_id
|
|
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
|
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
|
|
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
|
|
LEFT JOIN users target_user ON target_user.id=thread.target_id AND thread.target_type='profile'
|
|
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id`;
|
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
|
const total = Number((await (await database.prepare(
|
|
`SELECT COUNT(*) AS count ${joins} ${where}`
|
|
)).get(...args))?.count ?? 0);
|
|
const pagination = getPagination(url, total);
|
|
const rows = await (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.status,
|
|
comments.created_at,comments.updated_at,comments.removal_reason,
|
|
comments.author_id,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
|
|
profile.public_slug AS author_public_slug,target_profile.public_slug AS target_public_slug,
|
|
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
|
|
thread.target_type,thread.target_id,builds.slug,
|
|
COALESCE(builds.title,target_profile.display_name,target_user.username,'Community content') AS target_title
|
|
${joins} ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`))
|
|
.all(...args, pagination.perPage, pagination.offset);
|
|
return {
|
|
tab,
|
|
status,
|
|
search,
|
|
summary,
|
|
items: rows.map((row) => ({
|
|
id: String(row.id),
|
|
bodyHtml: renderUserMarkdown(row.body_markdown),
|
|
status: String(row.status),
|
|
createdAt: String(row.created_at),
|
|
updatedAt: String(row.updated_at),
|
|
moderationReason: row.removal_reason ? String(row.removal_reason) : null,
|
|
targetType: String(row.target_type),
|
|
targetTitle: String(row.target_title),
|
|
targetHref: targetHref(String(row.target_type), row.target_id, row.slug, row.target_public_slug),
|
|
author: {
|
|
id: String(row.author_id),
|
|
displayName: String(row.display_name),
|
|
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
|
|
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.author_id), row.avatar_updated_at)
|
|
}
|
|
})),
|
|
pagination,
|
|
sort: { key: sort.key, direction: sort.direction },
|
|
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
|
|
};
|
|
}
|
|
|
|
const sort = getRegistrySort(url, {
|
|
updated: {
|
|
asc: 'profile.updated_at ASC',
|
|
desc: 'profile.updated_at DESC',
|
|
defaultDirection: 'desc'
|
|
},
|
|
agent: {
|
|
asc: 'display_name COLLATE NOCASE ASC',
|
|
desc: 'display_name COLLATE NOCASE DESC'
|
|
}
|
|
}, 'updated', 'desc');
|
|
const conditions = [
|
|
status === 'all'
|
|
? "((profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0) OR moderation.bio_hidden=1)"
|
|
: status === 'visible'
|
|
? 'profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0'
|
|
: 'moderation.bio_hidden=1',
|
|
'length(trim(profile.bio_markdown))>0'
|
|
];
|
|
const args: unknown[] = [];
|
|
if (search) {
|
|
conditions.push(`(profile.bio_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
OR COALESCE(profile.display_name,users.username) LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
|
|
args.push(pattern, pattern);
|
|
}
|
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
|
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count
|
|
FROM user_profiles profile JOIN users ON users.id=profile.user_id
|
|
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
|
${where}`)).get(...args))?.count ?? 0);
|
|
const pagination = getPagination(url, total);
|
|
const rows = await (await database.prepare(`SELECT profile.user_id,profile.public_slug,profile.bio_markdown,profile.is_public,
|
|
profile.updated_at,COALESCE(profile.display_name,users.username) AS display_name,
|
|
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
|
|
moderation.reason AS moderation_reason
|
|
FROM user_profiles profile
|
|
JOIN users ON users.id=profile.user_id
|
|
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
|
|
LEFT JOIN profile_moderation moderation ON moderation.user_id=users.id
|
|
${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`))
|
|
.all(...args, pagination.perPage, pagination.offset);
|
|
return {
|
|
tab,
|
|
status,
|
|
search,
|
|
summary,
|
|
items: rows.map((row) => ({
|
|
id: String(row.user_id),
|
|
bodyHtml: renderUserMarkdown(row.bio_markdown),
|
|
status,
|
|
updatedAt: String(row.updated_at),
|
|
moderationReason: row.moderation_reason ? String(row.moderation_reason) : null,
|
|
agent: {
|
|
id: String(row.user_id),
|
|
displayName: String(row.display_name),
|
|
publicSlug: row.public_slug ? String(row.public_slug) : null,
|
|
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.user_id), row.avatar_updated_at)
|
|
}
|
|
})),
|
|
pagination,
|
|
sort: { key: sort.key, direction: sort.direction },
|
|
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
|
|
};
|
|
};
|
|
|
|
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();
|
|
const id = String(form.get('id') ?? '').trim();
|
|
const reason = requiredReason(form.get('reason'));
|
|
if (!id || !reason) return actionFailure(400, 'Choose a comment and provide a brief removal reason.');
|
|
const database = await getDatabase();
|
|
const comment = await (await database.prepare(`SELECT comments.*,thread.target_type,thread.target_id,builds.slug
|
|
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
|
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
|
|
WHERE comments.id=? AND comments.status='visible'`)).get(id);
|
|
if (!comment) return actionFailure(404, 'That visible comment no longer exists.');
|
|
const before = {
|
|
status: 'visible',
|
|
bodyMarkdown: String(comment.body_markdown),
|
|
authorId: String(comment.author_id),
|
|
threadId: String(comment.thread_id),
|
|
targetType: String(comment.target_type),
|
|
targetId: String(comment.target_id),
|
|
updatedAt: String(comment.updated_at)
|
|
};
|
|
const now = new Date().toISOString();
|
|
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, id);
|
|
if (!result.changes) return actionFailure(409, 'That comment changed before it could be removed.');
|
|
const after = { ...before, status: 'removed', removedAt: now, removedBy: operator.id, reason, updatedAt: now };
|
|
await recordIsacAudit({
|
|
actorId: operator.id,
|
|
category: 'community_moderation',
|
|
action: 'comment.removed',
|
|
entityId: id,
|
|
before,
|
|
after
|
|
});
|
|
await createNotification({
|
|
userId: String(comment.author_id),
|
|
category: 'isac',
|
|
source: 'ISAC COMMUNITY OVERSIGHT',
|
|
title: 'Comment removed',
|
|
body: `Community Oversight removed one of your comments. Reason: ${reason}`,
|
|
link: targetHref(String(comment.target_type), comment.target_id, comment.slug),
|
|
actions: [{
|
|
label: 'VIEW DISCUSSION',
|
|
href: targetHref(String(comment.target_type), comment.target_id, comment.slug)
|
|
}],
|
|
mandatory: true
|
|
});
|
|
return { communitySaved: true, communityResultToken: randomUUID() };
|
|
},
|
|
|
|
restoreComment: async ({ cookies, request }) => {
|
|
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
|
|
const form = await request.formData();
|
|
const id = String(form.get('id') ?? '').trim();
|
|
const reason = requiredReason(form.get('reason'));
|
|
if (!id || !reason) return actionFailure(400, 'Choose a comment and provide a brief restoration reason.');
|
|
const database = await getDatabase();
|
|
const comment = await (await database.prepare(`SELECT * FROM comments WHERE id=? AND status='removed'`)).get(id);
|
|
if (!comment) return actionFailure(404, 'That removed comment no longer exists.');
|
|
const audit = await (await database.prepare(`SELECT after_json FROM audit_log
|
|
WHERE entity_type='community_moderation' AND entity_id=? AND action='comment.removed'
|
|
ORDER BY created_at DESC LIMIT 1`)).get(id);
|
|
const expected = parseAuditSnapshot(audit?.after_json);
|
|
if (
|
|
!expected
|
|
|| expected.status !== 'removed'
|
|
|| String(expected.removedAt ?? '') !== String(comment.removed_at ?? '')
|
|
|| String(expected.updatedAt ?? '') !== String(comment.updated_at ?? '')
|
|
) return actionFailure(409, 'This comment was not removed by Community Oversight or changed after removal.');
|
|
const before = {
|
|
status: 'removed',
|
|
bodyMarkdown: String(comment.body_markdown),
|
|
authorId: String(comment.author_id),
|
|
threadId: String(comment.thread_id),
|
|
removedAt: String(comment.removed_at),
|
|
removedBy: String(comment.removed_by),
|
|
removalReason: String(comment.removal_reason),
|
|
updatedAt: String(comment.updated_at)
|
|
};
|
|
const now = new Date().toISOString();
|
|
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='removed' AND updated_at=?`))
|
|
.run(now, id, comment.updated_at);
|
|
if (!result.changes) return actionFailure(409, 'That comment changed before it could be restored.');
|
|
await recordIsacAudit({
|
|
actorId: operator.id,
|
|
category: 'community_moderation',
|
|
action: 'comment.restored',
|
|
entityId: id,
|
|
before,
|
|
after: {
|
|
status: 'visible',
|
|
bodyMarkdown: String(comment.body_markdown),
|
|
authorId: String(comment.author_id),
|
|
threadId: String(comment.thread_id),
|
|
restorationReason: reason,
|
|
updatedAt: now
|
|
}
|
|
});
|
|
return { communitySaved: true, communityResultToken: randomUUID() };
|
|
},
|
|
|
|
hideBio: async ({ cookies, request }) => {
|
|
const operator = await requirePermission(await getSessionUser(cookies), PROFILE_PERMISSION);
|
|
const form = await request.formData();
|
|
const id = String(form.get('id') ?? '').trim();
|
|
const reason = requiredReason(form.get('reason'));
|
|
if (!id || !reason) return actionFailure(400, 'Choose a profile and provide a brief reason for hiding it.');
|
|
const database = await getDatabase();
|
|
const profile = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,
|
|
profile.is_public,profile.bio_updated_at,profile.updated_at,
|
|
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason,
|
|
moderation.moderated_at,moderation.moderated_by
|
|
FROM user_profiles profile
|
|
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
|
WHERE profile.user_id=? AND profile.is_public=1
|
|
AND COALESCE(moderation.bio_hidden,0)=0 AND length(trim(profile.bio_markdown))>0`)).get(id);
|
|
if (!profile) return actionFailure(404, 'That visible profile biography no longer exists.');
|
|
const before = {
|
|
bioMarkdown: String(profile.bio_markdown),
|
|
isPublic: true,
|
|
bioHidden: false,
|
|
bioUpdatedAt: profile.bio_updated_at ? String(profile.bio_updated_at) : null,
|
|
profileUpdatedAt: String(profile.updated_at),
|
|
moderation: profile.moderated_at ? {
|
|
reason: profile.reason ? String(profile.reason) : null,
|
|
moderatedAt: String(profile.moderated_at),
|
|
moderatedBy: profile.moderated_by ? String(profile.moderated_by) : null
|
|
} : null
|
|
};
|
|
const now = new Date().toISOString();
|
|
const result = 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
|
|
WHERE profile_moderation.bio_hidden=0`)).run(id, reason, now, operator.id);
|
|
if (!result.changes) return actionFailure(409, 'That profile changed before it could be hidden.');
|
|
const after = {
|
|
...before,
|
|
bioHidden: true,
|
|
reason,
|
|
moderatedAt: now,
|
|
moderatedBy: operator.id
|
|
};
|
|
await recordIsacAudit({
|
|
actorId: operator.id,
|
|
category: 'community_moderation',
|
|
action: 'profile.hidden',
|
|
entityId: id,
|
|
before,
|
|
after
|
|
});
|
|
await createNotification({
|
|
userId: id,
|
|
category: 'isac',
|
|
source: 'ISAC COMMUNITY OVERSIGHT',
|
|
title: 'Agent profile hidden',
|
|
body: `Community Oversight hid your public Agent profile. Your biography is preserved but is not publicly visible. Reason: ${reason}`,
|
|
link: '/account',
|
|
actions: [{ label: 'REVIEW PROFILE', href: '/account' }],
|
|
mandatory: true
|
|
});
|
|
return { communitySaved: true, communityResultToken: randomUUID() };
|
|
},
|
|
|
|
restoreBio: async ({ cookies, request }) => {
|
|
const operator = await requirePermission(await getSessionUser(cookies), PROFILE_PERMISSION);
|
|
const form = await request.formData();
|
|
const id = String(form.get('id') ?? '').trim();
|
|
const reason = requiredReason(form.get('reason'));
|
|
if (!id || !reason) return actionFailure(400, 'Choose a profile and provide a brief restoration reason.');
|
|
const database = await getDatabase();
|
|
const profile = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,
|
|
profile.is_public,profile.bio_updated_at,profile.updated_at,
|
|
moderation.bio_hidden,moderation.reason,moderation.moderated_at,moderation.moderated_by
|
|
FROM user_profiles profile
|
|
JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
|
WHERE profile.user_id=? AND moderation.bio_hidden=1
|
|
AND length(trim(profile.bio_markdown))>0`)).get(id);
|
|
if (!profile) return actionFailure(404, 'That hidden profile biography no longer exists.');
|
|
const audit = await (await database.prepare(`SELECT after_json FROM audit_log
|
|
WHERE entity_type='community_moderation' AND entity_id=? AND action='profile.hidden'
|
|
ORDER BY created_at DESC LIMIT 1`)).get(id);
|
|
const expected = parseAuditSnapshot(audit?.after_json);
|
|
if (
|
|
!expected
|
|
|| expected.bioHidden !== true
|
|
|| String(expected.bioMarkdown ?? '') !== String(profile.bio_markdown)
|
|
|| String(expected.bioUpdatedAt ?? '') !== String(profile.bio_updated_at ?? '')
|
|
|| String(expected.profileUpdatedAt ?? '') !== String(profile.updated_at)
|
|
|| String(expected.moderatedAt ?? '') !== String(profile.moderated_at)
|
|
|| String(expected.moderatedBy ?? '') !== String(profile.moderated_by)
|
|
) return actionFailure(409, 'This profile was not hidden by Community Oversight or changed after it was hidden.');
|
|
const before = {
|
|
bioMarkdown: String(profile.bio_markdown),
|
|
isPublic: Boolean(profile.is_public),
|
|
bioHidden: true,
|
|
bioUpdatedAt: profile.bio_updated_at ? String(profile.bio_updated_at) : null,
|
|
profileUpdatedAt: String(profile.updated_at),
|
|
reason: String(profile.reason),
|
|
moderatedAt: String(profile.moderated_at),
|
|
moderatedBy: String(profile.moderated_by)
|
|
};
|
|
const now = new Date().toISOString();
|
|
const result = await (await database.prepare(`UPDATE profile_moderation
|
|
SET bio_hidden=0,reason=?,moderated_at=?,moderated_by=?
|
|
WHERE user_id=? AND bio_hidden=1 AND moderated_at=?`))
|
|
.run(reason, now, operator.id, id, profile.moderated_at);
|
|
if (!result.changes) return actionFailure(409, 'That profile changed before it could be restored.');
|
|
await recordIsacAudit({
|
|
actorId: operator.id,
|
|
category: 'community_moderation',
|
|
action: 'profile.restored',
|
|
entityId: id,
|
|
before,
|
|
after: {
|
|
...before,
|
|
bioHidden: false,
|
|
restorationReason: reason,
|
|
moderatedAt: now,
|
|
moderatedBy: operator.id
|
|
}
|
|
});
|
|
return { communitySaved: true, communityResultToken: randomUUID() };
|
|
}
|
|
};
|