Add public agent profiles and community moderation
This commit is contained in:
@@ -33,12 +33,15 @@
|
||||
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
|
||||
];
|
||||
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
|
||||
const oversightSections: Array<{ href: string; label: string; permission: string; reviewKey?: 'data' | 'avatars' }> = [
|
||||
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' }> = [
|
||||
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
|
||||
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
|
||||
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'] },
|
||||
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
|
||||
];
|
||||
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
|
||||
const visibleOversightSections = $derived(oversightSections.filter((section) =>
|
||||
section.permissions?.some(can) || (section.permission ? can(section.permission) : false)
|
||||
));
|
||||
const systemSections = [
|
||||
{ href: '/isac/site-controls', label: 'Site controls', permission: 'site.settings.manage' },
|
||||
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' },
|
||||
|
||||
@@ -87,6 +87,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
|
||||
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
|
||||
SUM(CASE WHEN entity_type='community_moderation' THEN 1 ELSE 0 END) AS community_moderation,
|
||||
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
|
||||
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
|
||||
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
{value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'},
|
||||
{value:'ai_assistance',label:'AI ASSISTANCE',count:data.summary.ai_assistance,description:'Permission-gated mechanic drafting'},
|
||||
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
|
||||
{value:'community_moderation',label:'COMMUNITY',count:data.summary.community_moderation,description:'Profile and comment moderation'},
|
||||
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
|
||||
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
|
||||
,{value:'system_configuration',label:'SYSTEM CONFIG',count:data.summary.system_configuration,description:'Automated messages and options'}
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
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';
|
||||
|
||||
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) =>
|
||||
targetType === 'profile'
|
||||
? `/agents/${encodeURIComponent(String(targetId))}#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;
|
||||
}
|
||||
};
|
||||
|
||||
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 === 'bios'
|
||||
? 'bios'
|
||||
: requestedTab === 'comments'
|
||||
? 'comments'
|
||||
: canModerateComments ? 'comments' : 'bios';
|
||||
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 status = allowedStatuses.includes(String(url.searchParams.get('status')))
|
||||
? String(url.searchParams.get('status'))
|
||||
: '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)])
|
||||
);
|
||||
|
||||
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,
|
||||
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: {
|
||||
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
|
||||
bios: { visible: visibleBios, hidden: hiddenBios }
|
||||
},
|
||||
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),
|
||||
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,
|
||||
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.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: {
|
||||
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
|
||||
bios: { visible: visibleBios, hidden: hiddenBios }
|
||||
},
|
||||
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),
|
||||
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 = {
|
||||
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() };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
|
||||
type AgentSummary = { id: string; displayName: string; avatarUrl: string };
|
||||
type CommunityComment = {
|
||||
id: string;
|
||||
bodyHtml: string;
|
||||
status: 'visible' | 'removed';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
moderationReason: string | null;
|
||||
targetType: string;
|
||||
targetTitle: string;
|
||||
targetHref: string;
|
||||
author: AgentSummary;
|
||||
};
|
||||
type CommunityBio = {
|
||||
id: string;
|
||||
bodyHtml: string;
|
||||
status: 'visible' | 'hidden';
|
||||
updatedAt: string;
|
||||
moderationReason: string | null;
|
||||
agent: AgentSummary;
|
||||
};
|
||||
type CommunityItem = CommunityComment | CommunityBio;
|
||||
type ModerationAction = 'removeComment' | 'restoreComment' | 'hideBio' | 'restoreBio';
|
||||
type PageData = {
|
||||
tab: 'comments' | 'bios';
|
||||
status: string;
|
||||
search: string;
|
||||
summary: {
|
||||
comments: { visible: number; removed: number };
|
||||
bios: { visible: number; hidden: number };
|
||||
};
|
||||
items: CommunityItem[];
|
||||
pagination: any;
|
||||
permissions: { comments: boolean; profiles: boolean };
|
||||
};
|
||||
|
||||
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
|
||||
let reviewing = $state<CommunityItem | null>(null);
|
||||
let moderationAction = $state<ModerationAction | null>(null);
|
||||
let moderationReason = $state('');
|
||||
let confirming = $state(false);
|
||||
let handledResultToken = $state('');
|
||||
|
||||
const canModerateComments = $derived(data.permissions.comments);
|
||||
const canModerateProfiles = $derived(data.permissions.profiles);
|
||||
const isComment = (item: CommunityItem): item is CommunityComment => 'author' in item;
|
||||
const itemAgent = (item: CommunityItem) => isComment(item) ? item.author : item.agent;
|
||||
const itemTimestamp = (item: CommunityItem) => isComment(item) ? item.createdAt : item.updatedAt;
|
||||
const date = (value: string) => {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf())
|
||||
? 'DATE UNKNOWN'
|
||||
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed);
|
||||
};
|
||||
const actionRoute = $derived(
|
||||
moderationAction === 'removeComment' ? '?/removeComment'
|
||||
: moderationAction === 'restoreComment' ? '?/restoreComment'
|
||||
: moderationAction === 'hideBio' ? '?/hideBio'
|
||||
: '?/restoreBio'
|
||||
);
|
||||
const actionLabel = $derived(
|
||||
moderationAction === 'removeComment' ? 'REMOVE COMMENT'
|
||||
: moderationAction === 'restoreComment' ? 'RESTORE COMMENT'
|
||||
: moderationAction === 'hideBio' ? 'HIDE PROFILE BIO'
|
||||
: 'RESTORE PROFILE BIO'
|
||||
);
|
||||
const actionConsequence = $derived(
|
||||
moderationAction === 'removeComment'
|
||||
? 'The comment will no longer appear on its public target or the author’s public activity.'
|
||||
: moderationAction === 'restoreComment'
|
||||
? 'The comment will return to its public target and the author’s public activity.'
|
||||
: moderationAction === 'hideBio'
|
||||
? 'The current bio will be hidden from the Agent’s public profile.'
|
||||
: 'The bio will return to the Agent’s public profile.'
|
||||
);
|
||||
const statusItems = $derived(
|
||||
data.tab === 'comments'
|
||||
? [
|
||||
{ value: 'all', label: 'ALL', count: data.summary.comments.visible + data.summary.comments.removed, description: 'All public and moderated comments' },
|
||||
{ value: 'visible', label: 'VISIBLE', count: data.summary.comments.visible, description: 'Comments currently visible to the community' },
|
||||
{ value: 'removed', label: 'REMOVED', count: data.summary.comments.removed, description: 'Comments removed by Oversight' }
|
||||
]
|
||||
: [
|
||||
{ value: 'all', label: 'ALL', count: data.summary.bios.visible + data.summary.bios.hidden, description: 'All public and moderated bios' },
|
||||
{ value: 'visible', label: 'VISIBLE', count: data.summary.bios.visible, description: 'Bios currently visible on Agent profiles' },
|
||||
{ value: 'hidden', label: 'HIDDEN', count: data.summary.bios.hidden, description: 'Bios hidden by Oversight' }
|
||||
]
|
||||
);
|
||||
|
||||
function openModeration(item: CommunityItem) {
|
||||
reviewing = item;
|
||||
moderationAction = isComment(item)
|
||||
? item.status === 'removed' ? 'restoreComment' : 'removeComment'
|
||||
: item.status === 'hidden' ? 'restoreBio' : 'hideBio';
|
||||
moderationReason = '';
|
||||
confirming = false;
|
||||
}
|
||||
|
||||
function closeModeration() {
|
||||
reviewing = null;
|
||||
moderationAction = null;
|
||||
moderationReason = '';
|
||||
confirming = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const token = String(form?.communityResultToken ?? '');
|
||||
if (!token || token === handledResultToken) return;
|
||||
handledResultToken = token;
|
||||
if (form?.communitySaved) closeModeration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Community Oversight — ISAC</title></svelte:head>
|
||||
<svelte:window onkeydown={(event) => {
|
||||
if (event.key === 'Escape' && reviewing) closeModeration();
|
||||
}}/>
|
||||
|
||||
<div class="module-toolbar moderation-toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">COMMUNITY OVERSIGHT / PUBLIC CONTENT CONTROL</p>
|
||||
<h2>COMMUNITY OVERSIGHT</h2>
|
||||
<p>Review public discussion and Agent bios. Moderation preserves evidence, requires a reason, and remains reversible.</p>
|
||||
</div>
|
||||
<div class="queue-readout">
|
||||
<span>{data.tab === 'comments' ? 'VISIBLE COMMENTS' : 'VISIBLE BIOS'}</span>
|
||||
<strong>{data.tab === 'comments' ? data.summary.comments.visible : data.summary.bios.visible}</strong>
|
||||
<small>PUBLIC RECORDS</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="community-tabs" aria-label="Community oversight channels">
|
||||
{#if canModerateComments}
|
||||
<a class:active={data.tab === 'comments'} aria-current={data.tab === 'comments' ? 'page' : undefined} href="/isac/community?tab=comments&status=all">
|
||||
<span>COMMENTS</span><b>{data.summary.comments.visible + data.summary.comments.removed}</b>
|
||||
</a>
|
||||
{/if}
|
||||
{#if canModerateProfiles}
|
||||
<a class:active={data.tab === 'bios'} aria-current={data.tab === 'bios' ? 'page' : undefined} href="/isac/community?tab=bios&status=all">
|
||||
<span>PROFILE BIOS</span><b>{data.summary.bios.visible + data.summary.bios.hidden}</b>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<KpiFilters parameter="status" active={data.status} items={statusItems}/>
|
||||
|
||||
<form class="audit-filter community-filter">
|
||||
<input type="hidden" name="tab" value={data.tab}/>
|
||||
<input type="hidden" name="status" value={data.status}/>
|
||||
<input name="search" type="search" value={data.search} placeholder={data.tab === 'comments' ? 'AUTHOR, TARGET, OR COMMENT TEXT' : 'AGENT OR BIO TEXT'}/>
|
||||
<button class="secondary">SEARCH COMMUNITY →</button>
|
||||
</form>
|
||||
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
|
||||
<div class="community-registry">
|
||||
{#each data.items as item}
|
||||
<article class:moderated={item.status === 'removed' || item.status === 'hidden'}>
|
||||
<header>
|
||||
<AgentIdentityLink agent={itemAgent(item)} meta={isComment(item) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
|
||||
<div class="community-state">
|
||||
<span>{date(itemTimestamp(item))}</span>
|
||||
<b class:visible={item.status === 'visible'}>{item.status.toUpperCase()}</b>
|
||||
</div>
|
||||
</header>
|
||||
{#if isComment(item)}
|
||||
<div class="community-target">
|
||||
<span>{item.targetType.toUpperCase()}</span>
|
||||
<a href={item.targetHref}>{item.targetTitle} →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={`/agents/${encodeURIComponent(item.agent.id)}`}>VIEW AGENT PROFILE →</a></div>
|
||||
{/if}
|
||||
<div class="community-markdown-preview">
|
||||
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
|
||||
</div>
|
||||
{#if item.moderationReason}
|
||||
<div class="moderation-evidence"><span>LAST OVERSIGHT REASON</span><p>{item.moderationReason}</p></div>
|
||||
{/if}
|
||||
<footer>
|
||||
<span>{isComment(item) ? `COMMENT ${item.id.slice(0,8).toUpperCase()}` : `AGENT ${item.id.slice(0,8).toUpperCase()}`}</span>
|
||||
{#if (isComment(item) && canModerateComments) || (!isComment(item) && canModerateProfiles)}
|
||||
<button class={item.status === 'visible' ? 'danger' : 'secondary'} onclick={() => openModeration(item)}>
|
||||
{item.status === 'visible' ? isComment(item) ? 'REMOVE COMMENT →' : 'HIDE BIO →' : isComment(item) ? 'RESTORE COMMENT →' : 'RESTORE BIO →'}
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
</article>
|
||||
{:else}
|
||||
<div class="empty-dashboard">
|
||||
<strong>NO MATCHING {data.tab === 'comments' ? 'COMMENTS' : 'PROFILE BIOS'}</strong>
|
||||
<span>Adjust the status or search filters to inspect another part of the community record.</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if reviewing && moderationAction}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="community-review-title">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">COMMUNITY CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="community-review-title">{actionLabel}</h3></div>
|
||||
<button aria-label="Close community moderation review" onclick={closeModeration}>×</button>
|
||||
</div>
|
||||
<div class="community-review-identity">
|
||||
<AgentIdentityLink agent={itemAgent(reviewing)} meta={isComment(reviewing) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
|
||||
<span>{date(itemTimestamp(reviewing))}</span>
|
||||
</div>
|
||||
{#if isComment(reviewing)}
|
||||
<div class="community-target review-target"><span>{reviewing.targetType.toUpperCase()}</span><a href={reviewing.targetHref}>{reviewing.targetTitle} →</a></div>
|
||||
{/if}
|
||||
<div class="community-review-content"><MarkdownContent html={reviewing.bodyHtml} emptyMessage="No content is available for review."/></div>
|
||||
{#if reviewing.moderationReason}<div class="moderation-evidence"><span>PREVIOUS OVERSIGHT REASON</span><p>{reviewing.moderationReason}</p></div>{/if}
|
||||
|
||||
{#if !confirming}
|
||||
<label class="field moderation-reason">
|
||||
OVERSIGHT REASON — REQUIRED
|
||||
<textarea bind:value={moderationReason} maxlength="500" required placeholder="Record a specific policy, safety, or restoration reason…"></textarea>
|
||||
</label>
|
||||
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
|
||||
<div class="moderation-warning" class:rejecting={moderationAction === 'removeComment' || moderationAction === 'hideBio'}>
|
||||
<b>!</b><span>{actionConsequence} The original content and this reason remain available to authorized Oversight.</span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" onclick={closeModeration}>CANCEL</button>
|
||||
<button type="button" class="primary" disabled={!moderationReason.trim()} onclick={() => confirming = true}>REVIEW ACTION →</button>
|
||||
</div>
|
||||
{:else}
|
||||
{@const reviewingId = reviewing.id}
|
||||
<div class="community-final-confirm" role="alert">
|
||||
<div><span>FINAL CONFIRMATION</span><strong>{actionLabel}?</strong><p>{actionConsequence}</p></div>
|
||||
<dl><dt>OVERSIGHT REASON</dt><dd>{moderationReason}</dd></dl>
|
||||
</div>
|
||||
<PendingForm action={actionRoute}>
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="id" value={reviewingId}/>
|
||||
<input type="hidden" name="reason" value={moderationReason}/>
|
||||
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" disabled={pending} onclick={() => confirming = false}>BACK</button>
|
||||
<button class={moderationAction === 'removeComment' || moderationAction === 'hideBio' ? 'danger' : 'primary'} disabled={pending} aria-busy={pending}>
|
||||
{pending ? 'PROCESSING…' : `CONFIRM ${actionLabel} →`}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.community-tabs{height:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0 0 8px;border:1px solid var(--shd-border);background:#0d1011}
|
||||
.community-tabs a{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 11px;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;color:var(--shd-text-muted)}.community-tabs a:first-child{border-left:0}.community-tabs a span,.community-tabs a b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-tabs a b{color:var(--shd-text-dim)}.community-tabs a:hover,.community-tabs a:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)}.community-tabs a.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.community-tabs a.active b{color:var(--shd-orange)}
|
||||
.community-filter{grid-template-columns:minmax(0,1fr) auto}.community-registry{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:7px}.community-registry>article{min-width:0;display:grid;grid-template-rows:auto auto minmax(100px,1fr) auto;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}.community-registry>article.moderated{border-left-color:var(--shd-danger)}
|
||||
.community-registry article>header{min-height:55px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>header :global(.agent-identity){flex:1}.community-state{display:grid;justify-items:end;gap:4px}.community-state span,.community-state b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-state span{color:var(--shd-text-muted)}.community-state b{padding:3px 5px;border:1px solid var(--shd-danger);color:var(--shd-danger)}.community-state b.visible{border-color:var(--shd-success);color:var(--shd-success)}
|
||||
.community-target{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-bottom:1px solid var(--shd-border-soft);background:#0d1011}.community-target span,.community-target a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-target span{color:var(--shd-text-muted)}.community-target a{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.community-markdown-preview{max-height:150px;overflow:hidden;padding:11px;position:relative}.community-markdown-preview:after{position:absolute;right:0;bottom:0;left:0;height:25px;background:linear-gradient(transparent,var(--shd-surface));content:"";pointer-events:none}
|
||||
.moderation-evidence{display:grid;gap:4px;margin:0 10px 9px;padding:8px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-danger);background:#1b1111}.moderation-evidence span{color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.moderation-evidence p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.community-registry article>footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>footer>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-registry article>footer button{min-height:36px;padding:0 10px}
|
||||
.community-review-modal{width:min(900px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.community-review-identity{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border-bottom:1px solid var(--shd-border)}.community-review-identity :global(.agent-identity){flex:1}.community-review-identity>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.review-target{border-bottom:1px solid var(--shd-border)}.community-review-content{max-height:330px;overflow:auto;padding:14px;border-bottom:1px solid var(--shd-border);background:#0d1011}.community-review-content :global(.markdown-content){max-width:none}.moderation-reason{margin:10px}.moderation-reason textarea{min-height:90px}.community-final-confirm{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.65fr);gap:9px;margin:10px;padding:10px;border:1px solid var(--shd-orange);background:#1b160d}.community-final-confirm>div{display:grid;gap:4px}.community-final-confirm span,.community-final-confirm dt{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-final-confirm p,.community-final-confirm dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.community-final-confirm dl{display:grid;align-content:start;gap:5px;margin:0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.community-review-modal>.moderation-warning,.community-review-modal>.form-actions,.community-review-modal :global(form > .form-actions),.community-review-modal :global(form > .error){margin-right:10px;margin-left:10px}
|
||||
@media(max-width:900px){.community-registry{grid-template-columns:1fr}}
|
||||
@media(max-width:650px){.community-tabs{grid-template-columns:1fr}.community-tabs a{min-height:52px;border-top:1px solid var(--shd-border);border-left:0}.community-tabs a:first-child{border-top:0}.community-filter{grid-template-columns:1fr}.community-filter button{min-height:44px}.community-registry article>header,.community-registry article>footer,.community-review-identity,.community-target{align-items:stretch;flex-direction:column}.community-state{justify-items:start}.community-target a{white-space:normal}.community-registry article>footer button{width:100%;min-height:44px}.community-final-confirm{grid-template-columns:1fr}.community-review-modal>.form-actions,.community-review-modal :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.community-review-modal button{min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.community-tabs a,.community-registry button{transition:none}}
|
||||
</style>
|
||||
@@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
deleted_accounts.deleted_at,
|
||||
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
|
||||
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
|
||||
(SELECT COUNT(*) FROM comments WHERE author_id=users.id) AS comment_count
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
@@ -76,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
|
||||
(SELECT provider FROM login_events WHERE user_id=users.id AND succeeded=1 ORDER BY created_at DESC LIMIT 1) AS last_login_provider,
|
||||
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
|
||||
(SELECT COUNT(*) FROM comments WHERE author_id=users.id) AS comment_count
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
|
||||
@@ -30,12 +30,17 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
|
||||
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
|
||||
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
|
||||
const commentWhere = `comments.user_id=? AND (comments.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR builds.title LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
|
||||
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
|
||||
const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)];
|
||||
const [loginCount, buildCount, commentCount] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere}`)).get(...commentArgs)
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||
JOIN comment_threads ON comment_threads.id=comments.thread_id
|
||||
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
|
||||
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
|
||||
WHERE ${commentWhere}`)).get(...commentArgs)
|
||||
]);
|
||||
const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login');
|
||||
const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build');
|
||||
@@ -67,8 +72,13 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
|
||||
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
|
||||
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
|
||||
(await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug
|
||||
FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
|
||||
(await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
|
||||
COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug
|
||||
FROM comments
|
||||
JOIN comment_threads ON comment_threads.id=comments.thread_id
|
||||
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
|
||||
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
|
||||
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
|
||||
,getAuthenticationMethods(database, params.id)
|
||||
,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id)
|
||||
,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id)
|
||||
|
||||
Reference in New Issue
Block a user