668 lines
30 KiB
TypeScript
668 lines
30 KiB
TypeScript
import { avatarUrl } from '$lib/avatar';
|
||
import { getSessionUser } from '$lib/server/auth/session';
|
||
import { getDatabase } from '$lib/server/database';
|
||
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
|
||
import { createNotification } from '$lib/server/notifications';
|
||
import {
|
||
getCommunityModerationPolicy,
|
||
moderateCommunityText,
|
||
createUserContentReport,
|
||
recordCommunityModerationScan
|
||
} from '$lib/server/community-moderation';
|
||
import { error, fail, redirect } from '@sveltejs/kit';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { queueCurrentAvatarReview } from '$lib/server/avatars';
|
||
import { agentProfileHref } from '$lib/agents';
|
||
import { resolveAgentProfile } from '$lib/server/agent-profiles';
|
||
import {
|
||
orderProfileCommentThread,
|
||
profileCommentNotificationRecipient
|
||
} from '$lib/server/profile-comment-thread';
|
||
import {
|
||
notifyProfileCommentFollowers,
|
||
profileCommentConversation
|
||
} from '$lib/server/profile-comment-follows';
|
||
import { loadAccountControlData } from '$lib/server/account-control-data';
|
||
import { saveProfileBio, updateProfileVisibility } from '$lib/server/profile-settings';
|
||
import type { Actions, PageServerLoad } from './$types';
|
||
|
||
const COMMENT_MAX_CHARACTERS = 2_000;
|
||
const RAPID_COMMENT_SECONDS = 10;
|
||
|
||
const unavailable = (row: Record<string, unknown> | null | undefined) => {
|
||
const banned = typeof row?.banned_until === 'string' && new Date(row.banned_until) > new Date();
|
||
return !row || Boolean(row.deleted_at || row.blocked_at || banned);
|
||
};
|
||
|
||
const commentHref = (
|
||
targetType: string,
|
||
slug: unknown,
|
||
targetId: string,
|
||
targetPublicSlug: unknown,
|
||
commentId: string
|
||
) => {
|
||
if (targetType === 'profile') {
|
||
return agentProfileHref({
|
||
id: targetId,
|
||
publicSlug: typeof targetPublicSlug === 'string' ? targetPublicSlug : null
|
||
}, `profile-comment-${commentId}`);
|
||
}
|
||
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comment-${encodeURIComponent(commentId)}`;
|
||
};
|
||
|
||
export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||
const database = await getDatabase();
|
||
const viewer = await getSessionUser(cookies);
|
||
const resolved = await resolveAgentProfile(database, params.id);
|
||
if (!resolved) error(404, 'Agent profile not found');
|
||
const agentId = resolved.id;
|
||
const agent = await (await database.prepare(`SELECT users.id,
|
||
COALESCE(profile.display_name,users.username) AS display_name,
|
||
profile.public_slug,profile.bio_markdown,profile.is_public,profile.bio_updated_at,
|
||
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason,
|
||
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
|
||
status.blocked_at,status.banned_until,deleted.deleted_at
|
||
FROM users
|
||
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||
LEFT JOIN profile_moderation moderation ON moderation.user_id=users.id
|
||
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
|
||
LEFT JOIN user_status status ON status.user_id=users.id
|
||
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
|
||
WHERE users.id=?`)).get(agentId);
|
||
const isSelf = viewer?.id === agentId;
|
||
if (
|
||
unavailable(agent)
|
||
|| (!Boolean(agent?.is_public) && !isSelf)
|
||
) error(404, 'Agent profile not found');
|
||
if (params.id !== String(agent.public_slug)) {
|
||
redirect(308, agentProfileHref({ id: agentId, publicSlug: String(agent.public_slug) }));
|
||
}
|
||
const [pendingBioRevision, accountControl] = isSelf
|
||
? await Promise.all([
|
||
(await database.prepare(`SELECT revision.body_markdown,revision.created_at,scan.reason
|
||
FROM moderated_content_revisions revision
|
||
JOIN content_moderation_scans scan ON scan.revision_id=revision.id
|
||
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
|
||
WHERE revision.content_type='profile_bio' AND revision.content_id=?
|
||
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL
|
||
ORDER BY revision.created_at DESC LIMIT 1`)).get(agentId),
|
||
loadAccountControlData(database, agentId, url.searchParams.get('control'))
|
||
])
|
||
: [null, null];
|
||
|
||
const [roleRows, buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
|
||
await Promise.all([
|
||
(await database.prepare(`SELECT roles.name FROM user_roles
|
||
JOIN roles ON roles.id=user_roles.role_id
|
||
WHERE user_roles.user_id=?
|
||
ORDER BY roles.name COLLATE NOCASE,roles.id`)).all(agentId),
|
||
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
|
||
FROM builds WHERE owner_id=? AND visibility='public'
|
||
AND (guide_body IS NULL OR length(trim(guide_body))=0)
|
||
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
|
||
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
|
||
FROM builds WHERE owner_id=? AND visibility='public'
|
||
AND guide_body IS NOT NULL AND length(trim(guide_body))>0
|
||
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
|
||
(await database.prepare(`SELECT comments.id,comments.created_at,
|
||
thread.target_type,thread.target_id,builds.title AS build_title,builds.slug,
|
||
COALESCE(target_profile.display_name,target_user.username) AS target_display_name,
|
||
target_profile.public_slug AS target_public_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')
|
||
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
|
||
LEFT JOIN deleted_accounts target_deleted ON target_deleted.user_id=target_user.id
|
||
LEFT JOIN user_status target_status ON target_status.user_id=target_user.id
|
||
WHERE comments.author_id=? AND comments.status='visible' AND (
|
||
(thread.target_type IN ('build','guide') AND builds.visibility='public')
|
||
OR (thread.target_type='profile' AND COALESCE(target_profile.is_public,0)=1
|
||
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
|
||
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
|
||
)
|
||
ORDER BY comments.created_at DESC LIMIT 50`)).all(agentId, new Date().toISOString()),
|
||
(await database.prepare(`SELECT comments.id,comments.author_id,comments.parent_id,comments.body_markdown,
|
||
comments.status,comments.created_at,
|
||
COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
|
||
profile.public_slug,
|
||
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
|
||
FROM comment_threads thread
|
||
JOIN comments ON comments.thread_id=thread.id AND comments.status='visible'
|
||
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
|
||
WHERE thread.target_type='profile' AND thread.target_id=?
|
||
ORDER BY comments.created_at DESC LIMIT 100`)).all(agentId),
|
||
(await database.prepare(`SELECT COUNT(*) AS count 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')
|
||
LEFT JOIN user_profiles target_profile ON target_profile.user_id=thread.target_id
|
||
AND thread.target_type='profile'
|
||
LEFT JOIN deleted_accounts target_deleted ON target_deleted.user_id=thread.target_id
|
||
LEFT JOIN user_status target_status ON target_status.user_id=thread.target_id
|
||
WHERE comments.author_id=? AND comments.status='visible' AND (
|
||
(thread.target_type IN ('build','guide') AND builds.visibility='public')
|
||
OR (thread.target_type='profile' AND COALESCE(target_profile.is_public,0)=1
|
||
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
|
||
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
|
||
)`)).get(agentId, new Date().toISOString()),
|
||
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
||
WHERE thread.target_type='profile' AND thread.target_id=? AND comments.status='visible'`))
|
||
.get(agentId),
|
||
(await database.prepare(`SELECT
|
||
SUM(CASE WHEN guide_body IS NULL OR length(trim(guide_body))=0 THEN 1 ELSE 0 END) AS builds,
|
||
SUM(CASE WHEN guide_body IS NOT NULL AND length(trim(guide_body))>0 THEN 1 ELSE 0 END) AS guides
|
||
FROM builds WHERE owner_id=? AND visibility='public'`)).get(agentId)
|
||
]);
|
||
|
||
const loadedCommentIds = new Set(profileCommentRows.map((comment) => String(comment.id)));
|
||
let missingParentIds = [...new Set(profileCommentRows
|
||
.map((comment) => comment.parent_id ? String(comment.parent_id) : '')
|
||
.filter((parentId) => parentId && !loadedCommentIds.has(parentId)))];
|
||
for (let level = 0; missingParentIds.length && level < 100; level += 1) {
|
||
const placeholders = missingParentIds.map(() => '?').join(',');
|
||
const ancestors = await (await database.prepare(`SELECT comments.id,comments.author_id,
|
||
comments.parent_id,comments.body_markdown,comments.status,comments.created_at,
|
||
COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
|
||
profile.public_slug,
|
||
avatar.source AS avatar_source,avatar.value AS avatar_value,
|
||
avatar.updated_at AS avatar_updated_at
|
||
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
|
||
WHERE comments.id IN (${placeholders})
|
||
AND comments.status IN ('visible','removed')
|
||
AND thread.target_type='profile' AND thread.target_id=?`))
|
||
.all(...missingParentIds, agentId);
|
||
if (!ancestors.length) break;
|
||
profileCommentRows.push(...ancestors);
|
||
for (const ancestor of ancestors) loadedCommentIds.add(String(ancestor.id));
|
||
missingParentIds = [...new Set(ancestors
|
||
.map((ancestor) => ancestor.parent_id ? String(ancestor.parent_id) : '')
|
||
.filter((parentId) => parentId && !loadedCommentIds.has(parentId)))];
|
||
}
|
||
|
||
const commentAuthorIds = [...new Set(profileCommentRows
|
||
.map((comment) => comment.author_id ? String(comment.author_id) : '')
|
||
.filter(Boolean))];
|
||
const commentRoleRows = commentAuthorIds.length
|
||
? await (await database.prepare(`SELECT user_roles.user_id,roles.name
|
||
FROM user_roles
|
||
JOIN roles ON roles.id=user_roles.role_id
|
||
WHERE user_roles.user_id IN (${commentAuthorIds.map(() => '?').join(',')})
|
||
ORDER BY roles.name COLLATE NOCASE,roles.id`)).all(...commentAuthorIds)
|
||
: [];
|
||
const rolesByCommentAuthor = new Map<string, string[]>();
|
||
for (const row of commentRoleRows) {
|
||
const userId = String(row.user_id);
|
||
const roles = rolesByCommentAuthor.get(userId) ?? [];
|
||
roles.push(String(row.name));
|
||
rolesByCommentAuthor.set(userId, roles);
|
||
}
|
||
const threadedProfileComments = orderProfileCommentThread(profileCommentRows.map((comment) => {
|
||
const status = String(comment.status) === 'removed' ? 'removed' as const : 'visible' as const;
|
||
return {
|
||
id: String(comment.id),
|
||
parentId: comment.parent_id ? String(comment.parent_id) : null,
|
||
status,
|
||
bodyHtml: status === 'visible' ? renderUserMarkdown(comment.body_markdown) : '',
|
||
createdAt: String(comment.created_at),
|
||
author: {
|
||
id: String(comment.author_id),
|
||
displayName: String(comment.display_name),
|
||
publicSlug: comment.public_slug ? String(comment.public_slug) : null,
|
||
avatarUrl: avatarUrl(
|
||
comment.avatar_source,
|
||
comment.avatar_value,
|
||
String(comment.author_id),
|
||
comment.avatar_updated_at
|
||
),
|
||
roles: rolesByCommentAuthor.get(String(comment.author_id)) ?? []
|
||
},
|
||
canDelete: status === 'visible' && viewer?.id === String(comment.author_id),
|
||
canReport: status === 'visible' && Boolean(viewer) && viewer?.id !== String(comment.author_id)
|
||
};
|
||
}));
|
||
const followedRootIds = viewer && threadedProfileComments.length
|
||
? new Set((await (await database.prepare(`SELECT root_comment_id
|
||
FROM comment_thread_follows
|
||
WHERE user_id=? AND root_comment_id IN (${[...new Set(threadedProfileComments.map((comment) => comment.rootId))]
|
||
.map(() => '?').join(',')})`)).all(
|
||
viewer.id,
|
||
...[...new Set(threadedProfileComments.map((comment) => comment.rootId))]
|
||
)).map((row) => String(row.root_comment_id)))
|
||
: new Set<string>();
|
||
|
||
return {
|
||
agent: {
|
||
id: String(agent.id),
|
||
displayName: String(agent.display_name),
|
||
publicSlug: String(agent.public_slug),
|
||
avatarUrl: avatarUrl(
|
||
agent.avatar_source,
|
||
agent.avatar_value,
|
||
String(agent.id),
|
||
agent.avatar_updated_at
|
||
),
|
||
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown),
|
||
bioMarkdown: isSelf
|
||
? String(pendingBioRevision?.body_markdown ?? agent.bio_markdown ?? '')
|
||
: '',
|
||
isPublic: Boolean(agent.is_public),
|
||
bioHidden: Boolean(agent.bio_hidden),
|
||
bioModerationReason: isSelf && agent.moderation_reason
|
||
? String(agent.moderation_reason)
|
||
: null,
|
||
bioReviewPending: Boolean(pendingBioRevision),
|
||
bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
|
||
roles: roleRows.map((role) => String(role.name))
|
||
},
|
||
counts: {
|
||
builds: Number(contentCounts?.builds ?? 0),
|
||
guides: Number(contentCounts?.guides ?? 0),
|
||
authoredComments: Number(authoredCount?.count ?? 0),
|
||
profileComments: Number(profileCommentCount?.count ?? 0)
|
||
},
|
||
builds: buildRows.map((build) => ({
|
||
id: String(build.id),
|
||
slug: String(build.slug),
|
||
title: String(build.title),
|
||
summary: String(build.summary ?? ''),
|
||
updatedAt: String(build.updated_at)
|
||
})),
|
||
guides: guideRows.map((guide) => ({
|
||
id: String(guide.id),
|
||
slug: String(guide.slug),
|
||
title: String(guide.title),
|
||
summary: String(guide.summary ?? ''),
|
||
updatedAt: String(guide.updated_at)
|
||
})),
|
||
authoredComments: authoredCommentRows.map((comment) => {
|
||
const targetType = String(comment.target_type);
|
||
const targetId = String(comment.target_id);
|
||
return {
|
||
id: String(comment.id),
|
||
createdAt: String(comment.created_at),
|
||
targetType,
|
||
targetTitle: targetType === 'profile'
|
||
? `${String(comment.target_display_name ?? 'Agent')} profile`
|
||
: String(comment.build_title ?? 'Community content'),
|
||
targetHref: commentHref(
|
||
targetType,
|
||
comment.slug,
|
||
targetId,
|
||
comment.target_public_slug,
|
||
String(comment.id)
|
||
)
|
||
};
|
||
}),
|
||
profileComments: threadedProfileComments.map((comment) => ({
|
||
...comment,
|
||
isFollowing: followedRootIds.has(comment.rootId)
|
||
})),
|
||
viewer: {
|
||
id: viewer?.id ?? null,
|
||
canComment: Boolean(viewer) && Boolean(agent.is_public),
|
||
canReportProfile: Boolean(viewer) && !isSelf,
|
||
isSelf
|
||
},
|
||
accountControl
|
||
};
|
||
};
|
||
|
||
export const actions: Actions = {
|
||
saveBio: async ({ params, cookies, request }) => {
|
||
const viewer = await getSessionUser(cookies);
|
||
if (!viewer) return fail(401, { bioError: 'Sign in to edit your profile.', bioResultToken: randomUUID() });
|
||
const database = await getDatabase();
|
||
const owner = await resolveAgentProfile(database, params.id);
|
||
if (!owner || owner.id !== viewer.id) {
|
||
return fail(403, { bioError: 'You can edit only your own profile.', bioResultToken: randomUUID() });
|
||
}
|
||
const result = await saveProfileBio(viewer, (await request.formData()).get('bio'));
|
||
if (!result.ok) {
|
||
return fail(400, { bioError: result.error, bioResultToken: randomUUID() });
|
||
}
|
||
return {
|
||
bioSaved: true,
|
||
bioReviewQueued: result.reviewQueued,
|
||
bioMarkdown: result.bio,
|
||
bioResultToken: randomUUID()
|
||
};
|
||
},
|
||
|
||
setProfileVisibility: async ({ params, cookies, request }) => {
|
||
const viewer = await getSessionUser(cookies);
|
||
if (!viewer) return fail(401, {
|
||
profileVisibilityError: 'Sign in to change profile visibility.',
|
||
profileVisibilityResultToken: randomUUID()
|
||
});
|
||
const database = await getDatabase();
|
||
const owner = await resolveAgentProfile(database, params.id);
|
||
if (!owner || owner.id !== viewer.id) {
|
||
return fail(403, {
|
||
profileVisibilityError: 'You can change only your own profile.',
|
||
profileVisibilityResultToken: randomUUID()
|
||
});
|
||
}
|
||
const result = await updateProfileVisibility(
|
||
viewer,
|
||
(await request.formData()).get('isPublic')
|
||
);
|
||
if (!result.ok) {
|
||
return fail(400, {
|
||
profileVisibilityError: result.error,
|
||
profileVisibilityResultToken: randomUUID()
|
||
});
|
||
}
|
||
return {
|
||
profileVisibilitySaved: true,
|
||
profileIsPublic: result.isPublic,
|
||
profileVisibilityResultToken: randomUUID()
|
||
};
|
||
},
|
||
|
||
commentProfile: async ({ params, cookies, request }) => {
|
||
const commenter = await getSessionUser(cookies);
|
||
const formData = await request.formData();
|
||
const parentId = String(formData.get('parentId') ?? '').trim() || null;
|
||
const commentFailure = (status: number, profileCommentError: string) => fail(status, {
|
||
profileCommentError,
|
||
profileCommentParentId: parentId,
|
||
profileCommentResultToken: randomUUID()
|
||
});
|
||
if (!commenter) return commentFailure(401, 'Sign in to post a profile comment.');
|
||
const body = normalizeUserMarkdown(
|
||
formData.get('body'),
|
||
COMMENT_MAX_CHARACTERS
|
||
);
|
||
if (body === null || Array.from(body).length < 2) {
|
||
return commentFailure(
|
||
400,
|
||
`Comments must contain 2–${COMMENT_MAX_CHARACTERS.toLocaleString()} visible characters.`
|
||
);
|
||
}
|
||
const database = await getDatabase();
|
||
const owner = await resolveAgentProfile(database, params.id);
|
||
if (!owner) return commentFailure(404, 'Agent profile not found.');
|
||
const agentId = owner.id;
|
||
const moderationPolicy = await getCommunityModerationPolicy();
|
||
const now = new Date();
|
||
const rows = await (await database.prepare(`SELECT users.id,
|
||
COALESCE(profile.display_name,users.username) AS display_name,profile.is_public,
|
||
status.blocked_at,status.banned_until,deleted.deleted_at
|
||
FROM users
|
||
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||
LEFT JOIN user_status status ON status.user_id=users.id
|
||
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
|
||
WHERE users.id IN (?,?)`)).all(commenter.id, agentId);
|
||
const commenterState = rows.find((row) => row.id === commenter.id);
|
||
const ownerState = rows.find((row) => row.id === agentId);
|
||
if (unavailable(commenterState)) {
|
||
return commentFailure(403, 'This account cannot post comments.');
|
||
}
|
||
if (unavailable(ownerState) || !Boolean(ownerState?.is_public)) {
|
||
return commentFailure(404, 'Agent profile not found.');
|
||
}
|
||
|
||
const recent = await (await database.prepare(`SELECT comments.body_markdown,comments.created_at
|
||
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||
WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
|
||
AND thread.target_type='profile' AND thread.target_id=?
|
||
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, agentId);
|
||
if (typeof recent?.created_at === 'string') {
|
||
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
|
||
if (elapsed < RAPID_COMMENT_SECONDS) {
|
||
return commentFailure(429, 'Wait a few seconds before posting again.');
|
||
}
|
||
if (elapsed < moderationPolicy.duplicateWindowSeconds && recent.body_markdown === body) {
|
||
return commentFailure(409, 'That comment was already posted.');
|
||
}
|
||
}
|
||
|
||
const moderation = await moderateCommunityText({
|
||
contentType: 'comment',
|
||
text: body,
|
||
policy: moderationPolicy
|
||
});
|
||
const commentStatus = moderation.decision === 'approved' ? 'visible' : 'pending_review';
|
||
const createdAt = now.toISOString();
|
||
const proposedThreadId = randomUUID();
|
||
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
|
||
(id,target_type,target_id,created_at) VALUES(?,'profile',?,?)`))
|
||
.run(proposedThreadId, agentId, createdAt);
|
||
const thread = await (await database.prepare(
|
||
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
|
||
)).get(agentId);
|
||
if (!thread?.id) return commentFailure(500, 'The profile thread could not be opened.');
|
||
const parent = parentId
|
||
? await (await database.prepare(`SELECT parent.author_id
|
||
FROM comments parent
|
||
JOIN comment_threads thread ON thread.id=parent.thread_id
|
||
WHERE parent.id=? AND parent.status='visible' AND thread.id=?
|
||
AND thread.target_type='profile' AND thread.target_id=?`))
|
||
.get(parentId, thread.id, agentId)
|
||
: null;
|
||
if (parentId && !parent) {
|
||
return commentFailure(404, 'That message is no longer available for replies.');
|
||
}
|
||
const commentId = randomUUID();
|
||
const inserted = parentId
|
||
? await (await database.prepare(`INSERT INTO comments
|
||
(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
|
||
SELECT ?,parent.thread_id,?,parent.id,?,?,?,?
|
||
FROM comments parent
|
||
JOIN comment_threads thread ON thread.id=parent.thread_id
|
||
WHERE parent.id=? AND parent.status='visible' AND thread.id=?
|
||
AND thread.target_type='profile' AND thread.target_id=?`))
|
||
.run(
|
||
commentId,
|
||
commenter.id,
|
||
body,
|
||
commentStatus,
|
||
createdAt,
|
||
createdAt,
|
||
parentId,
|
||
thread.id,
|
||
agentId
|
||
)
|
||
: await (await database.prepare(`INSERT INTO comments
|
||
(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
|
||
VALUES(?,?,?,NULL,?,?,?,?)`))
|
||
.run(commentId, thread.id, commenter.id, body, commentStatus, createdAt, createdAt);
|
||
if (!inserted.changes) {
|
||
return commentFailure(409, 'That conversation changed before the reply could be posted.');
|
||
}
|
||
await recordCommunityModerationScan({
|
||
contentType: 'comment',
|
||
contentId: commentId,
|
||
authorId: commenter.id,
|
||
result: moderation
|
||
});
|
||
const parentAuthorId = parent?.author_id ? String(parent.author_id) : null;
|
||
const recipientId = profileCommentNotificationRecipient(
|
||
commenter.id,
|
||
parentAuthorId,
|
||
agentId
|
||
);
|
||
if (commentStatus === 'visible' && recipientId) {
|
||
const profileLink = agentProfileHref(owner, `profile-comment-${commentId}`);
|
||
const isDirectReplyRecipient = Boolean(parentAuthorId && recipientId === parentAuthorId);
|
||
await createNotification({
|
||
userId: recipientId,
|
||
category: 'comments',
|
||
source: 'agent',
|
||
title: parentId ? 'New profile reply' : 'New profile comment',
|
||
body: parentId
|
||
? isDirectReplyRecipient
|
||
? `${commenter.displayName} replied to your message on ${owner.displayName}'s Agent profile.`
|
||
: `${commenter.displayName} replied in a conversation on your Agent profile.`
|
||
: `${commenter.displayName} posted a comment on your Agent profile.`,
|
||
link: profileLink,
|
||
actions: [{
|
||
label: parentId ? 'VIEW REPLY' : 'VIEW COMMENT',
|
||
href: profileLink
|
||
}]
|
||
});
|
||
}
|
||
if (commentStatus === 'visible' && parentId) {
|
||
await notifyProfileCommentFollowers({
|
||
commentId,
|
||
authorId: commenter.id,
|
||
authorDisplayName: commenter.displayName,
|
||
profileOwnerId: agentId,
|
||
profileOwnerDisplayName: owner.displayName,
|
||
profileOwnerPublicSlug: owner.publicSlug,
|
||
directRecipientId: recipientId
|
||
});
|
||
}
|
||
return {
|
||
profileCommentSaved: true,
|
||
profileCommentReviewQueued: commentStatus === 'pending_review',
|
||
profileCommentId: commentId,
|
||
profileCommentParentId: parentId,
|
||
profileCommentResultToken: randomUUID()
|
||
};
|
||
},
|
||
|
||
toggleProfileCommentFollow: async ({ params, cookies, request }) => {
|
||
const viewer = await getSessionUser(cookies);
|
||
const formData = await request.formData();
|
||
const commentId = String(formData.get('commentId') ?? '').trim();
|
||
const intent = String(formData.get('intent') ?? '').trim();
|
||
const failure = (status: number, message: string) => fail(status, {
|
||
profileCommentFollowError: message,
|
||
profileCommentFollowId: commentId,
|
||
profileCommentFollowResultToken: randomUUID()
|
||
});
|
||
if (!viewer) return failure(401, 'Sign in to follow a conversation.');
|
||
if (!commentId || !['follow', 'unfollow'].includes(intent)) {
|
||
return failure(400, 'Choose a conversation and follow preference.');
|
||
}
|
||
const database = await getDatabase();
|
||
const owner = await resolveAgentProfile(database, params.id);
|
||
if (!owner) return failure(404, 'Agent profile not found.');
|
||
const conversation = await profileCommentConversation(commentId, owner.id);
|
||
if (!conversation) return failure(404, 'That conversation is no longer available.');
|
||
if (intent === 'follow') {
|
||
await (await database.prepare(`INSERT OR IGNORE INTO comment_thread_follows
|
||
(user_id,root_comment_id,created_at) VALUES(?,?,?)`))
|
||
.run(viewer.id, conversation.rootId, new Date().toISOString());
|
||
} else {
|
||
await (await database.prepare(
|
||
'DELETE FROM comment_thread_follows WHERE user_id=? AND root_comment_id=?'
|
||
)).run(viewer.id, conversation.rootId);
|
||
}
|
||
return {
|
||
profileCommentFollowSaved: true,
|
||
profileCommentFollowing: intent === 'follow',
|
||
profileCommentFollowId: commentId,
|
||
profileCommentFollowResultToken: randomUUID()
|
||
};
|
||
},
|
||
|
||
deleteProfileComment: async ({ params, cookies, request }) => {
|
||
const viewer = await getSessionUser(cookies);
|
||
if (!viewer) return fail(401, { profileCommentError: 'Sign in to remove a comment.' });
|
||
const commentId = String((await request.formData()).get('commentId') ?? '').trim();
|
||
if (!commentId) return fail(400, { profileCommentError: 'Choose a comment to remove.' });
|
||
const database = await getDatabase();
|
||
const owner = await resolveAgentProfile(database, params.id);
|
||
if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
|
||
const comment = await (await database.prepare(`SELECT comments.id,comments.author_id
|
||
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||
WHERE comments.id=? AND comments.status='visible'
|
||
AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, owner.id);
|
||
if (!comment) return fail(404, { profileCommentError: 'That comment is no longer available.' });
|
||
if (comment.author_id !== viewer.id) {
|
||
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
|
||
}
|
||
const removedAt = new Date().toISOString();
|
||
const removed = await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,removed_by=?,
|
||
removal_reason='Removed by author',updated_at=? WHERE id=? AND status='visible'`))
|
||
.run(removedAt, viewer.id, removedAt, commentId);
|
||
if (!removed.changes) {
|
||
return fail(409, { profileCommentError: 'That comment changed before it could be removed.' });
|
||
}
|
||
return {
|
||
profileCommentDeleted: true,
|
||
profileCommentId: commentId,
|
||
profileCommentResultToken: randomUUID()
|
||
};
|
||
},
|
||
|
||
reportContent: async ({ params, cookies, request }) => {
|
||
const reporter = await getSessionUser(cookies);
|
||
const form = await request.formData();
|
||
const contentType = String(form.get('contentType') ?? '');
|
||
const contentId = String(form.get('contentId') ?? '').trim();
|
||
const category = String(form.get('category') ?? '');
|
||
const details = String(form.get('details') ?? '').normalize('NFKC').trim();
|
||
const failure = (status: number, message: string) => fail(status, {
|
||
userReportError: message,
|
||
userReportContentId: contentId,
|
||
userReportContentType: contentType,
|
||
userReportResultToken: randomUUID()
|
||
});
|
||
if (!reporter) return failure(401, 'Sign in to request an Oversight review.');
|
||
if (!['comment', 'profile_bio', 'avatar'].includes(contentType)) {
|
||
return failure(400, 'Choose reportable content.');
|
||
}
|
||
if (!['spam', 'harassment', 'hate', 'sexual', 'violence', 'self_harm', 'privacy', 'other'].includes(category)) {
|
||
return failure(400, 'Choose the policy concern that best fits this report.');
|
||
}
|
||
if (details.length > 500 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(details)) {
|
||
return failure(400, 'Report details may contain up to 500 visible characters.');
|
||
}
|
||
const database = await getDatabase();
|
||
const owner = await resolveAgentProfile(database, params.id);
|
||
if (!owner) return failure(404, 'Agent profile not found.');
|
||
const agentId = owner.id;
|
||
if (contentType === 'comment') {
|
||
const comment = await (await database.prepare(`SELECT comments.author_id
|
||
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||
WHERE comments.id=? AND comments.status='visible'
|
||
AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, agentId);
|
||
if (!comment) return failure(404, 'That comment is no longer available.');
|
||
if (String(comment.author_id) === reporter.id) return failure(400, 'You cannot report your own comment.');
|
||
} else {
|
||
if (contentId !== agentId) return failure(400, 'That profile content does not match this Agent.');
|
||
if (reporter.id === agentId) return failure(400, 'You cannot report your own profile content.');
|
||
const profile = await (await database.prepare(`SELECT profile.bio_markdown,profile.is_public,
|
||
COALESCE(moderation.bio_hidden,0) AS bio_hidden,avatar.source AS avatar_source
|
||
FROM user_profiles profile
|
||
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
||
LEFT JOIN user_avatars avatar ON avatar.user_id=profile.user_id
|
||
WHERE profile.user_id=?`)).get(agentId);
|
||
if (!profile?.is_public) return failure(404, 'That profile is no longer available.');
|
||
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
|
||
return failure(404, 'That biography is no longer available.');
|
||
}
|
||
if (contentType === 'avatar' && profile.avatar_source !== 'upload') {
|
||
return failure(400, 'Preset SHD avatars are managed site assets and do not need an Agent report.');
|
||
}
|
||
}
|
||
const report = await createUserContentReport({
|
||
contentType: contentType as 'comment' | 'profile_bio' | 'avatar',
|
||
contentId,
|
||
reporterId: reporter.id,
|
||
category: category as 'spam' | 'harassment' | 'hate' | 'sexual' | 'violence' | 'self_harm' | 'privacy' | 'other',
|
||
details
|
||
});
|
||
if (!report) return failure(409, 'You already have a pending report for this content.');
|
||
if (contentType === 'avatar') {
|
||
await queueCurrentAvatarReview(database, agentId, { origin: 'user_report' });
|
||
}
|
||
return {
|
||
userReportSaved: true,
|
||
userReportContentId: contentId,
|
||
userReportContentType: contentType,
|
||
userReportResultToken: randomUUID()
|
||
};
|
||
}
|
||
};
|