feat(threads): add real-time forum and chat system

Implement DB schema, API, and WebSocket for channels and messages
Add frontend views, AI moderation, and admin management
This commit is contained in:
2026-05-07 11:28:14 +08:00
parent 23a7301598
commit cbb101336b
16 changed files with 3567 additions and 10 deletions

View File

@@ -5,9 +5,10 @@ import {
createApprovedCommentNotification,
createModerationResultNotification
} from './notifications.ts';
import { applyApprovedThreadMessage, publishThreadMessageModeration } from './threadsRealtime.ts';
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
export type AiModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
export type AiModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'thread-message';
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
@@ -254,6 +255,49 @@ const targetQueries: Record<
AND deleted_at IS NULL
RETURNING id
`
},
'thread-message': {
select: `
SELECT
tm.id,
tm.body,
tm.ai_moderation_status AS status,
tm.ai_moderation_language_code AS "languageCode",
tm.ai_moderation_reason AS reason,
tm.ai_moderation_content_hash AS "contentHash"
FROM thread_messages tm
JOIN threads t ON t.id = tm.thread_id
WHERE tm.id = $1
AND tm.deleted_at IS NULL
AND t.deleted_at IS NULL
`,
updateStatus: `
UPDATE thread_messages
SET ai_moderation_status = $2,
ai_moderation_language_code = $3,
ai_moderation_reason = CASE WHEN $2 IN ('rejected', 'failed') THEN $4 ELSE NULL END,
ai_moderation_checked_at = now(),
ai_moderation_updated_at = now()
WHERE id = $1
AND deleted_at IS NULL
`,
updateForReview: `
UPDATE thread_messages
SET ai_moderation_status = 'reviewing',
ai_moderation_language_code = $2,
ai_moderation_reason = NULL,
ai_moderation_content_hash = $3,
ai_moderation_checked_at = NULL,
ai_moderation_retry_count = CASE
WHEN $4::boolean THEN 0
WHEN $5::boolean THEN ai_moderation_retry_count + 1
ELSE ai_moderation_retry_count
END,
ai_moderation_updated_at = now()
WHERE id = $1
AND deleted_at IS NULL
RETURNING id
`
}
};
@@ -595,6 +639,15 @@ async function enqueuePendingAiModeration(): Promise<void> {
WHERE deleted_at IS NULL
AND ai_moderation_status IN ('unreviewed', 'reviewing')
UNION ALL
SELECT 'thread-message'::text AS type, tm.id
FROM thread_messages tm
JOIN threads t ON t.id = tm.thread_id
WHERE tm.deleted_at IS NULL
AND t.deleted_at IS NULL
AND tm.ai_moderation_status IN ('unreviewed', 'reviewing')
LIMIT $1
`,
[retryScanLimit]
@@ -715,9 +768,28 @@ async function updateTargetStatus(
}
try {
await createModerationResultNotification(target, status);
if (target.type === 'thread-message') {
if (status === 'approved') {
await applyApprovedThreadMessage(target.id);
} else {
const row = await queryOne<{ threadId: number }>(
'SELECT thread_id AS "threadId" FROM thread_messages WHERE id = $1',
[target.id]
);
if (row) {
await publishThreadMessageModeration(row.threadId, null);
}
}
return;
}
const notificationTarget = {
type: target.type as Exclude<AiModerationTargetType, 'thread-message'>,
id: target.id
};
await createModerationResultNotification(notificationTarget, status);
if (status === 'approved') {
await createApprovedCommentNotification(target);
await createApprovedCommentNotification(notificationTarget);
}
} catch (error) {
logger?.warn(

View File

@@ -945,7 +945,6 @@ export function setupNotificationWebSocketServer(server: Server, logger: Fastify
server.on('upgrade', async (request, socket) => {
const url = new URL(request.url ?? '/', 'http://localhost');
if (url.pathname !== '/api/notifications/ws') {
socket.destroy();
return;
}

View File

@@ -17,6 +17,13 @@ import {
type AiModerationStatus
} from './aiModeration.ts';
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
import {
createThreadWebSocketTicket,
publishThreadMessageCreated,
publishThreadMessageModeration,
publishThreadReactionUpdated,
publishThreadReadUpdated
} from './threadsRealtime.ts';
type QueryValue = string | string[] | undefined;
@@ -26,6 +33,53 @@ type ListPage<T> = {
nextCursor: string | null;
hasMore: boolean;
};
export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes';
export type ThreadReactionCounts = Record<ThreadReactionType, number>;
export type ThreadChannelTag = { id: number; name: string; sortOrder: number };
export type ThreadChannel = {
id: number;
name: string;
allowUserThreads: boolean;
sortOrder: number;
tags: ThreadChannelTag[];
languages: Array<{ code: string; name: string }>;
unreadCount: number;
};
export type ThreadSummary = {
id: number;
channelId: number;
title: string;
languageCode: string;
tags: ThreadChannelTag[];
locked: boolean;
messageCount: number;
lastActiveAt: Date;
createdAt: Date;
author: { id: number; displayName: string } | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
followed: boolean;
unread: boolean;
};
export type ThreadMessage = {
id: number;
threadId: number;
body: string;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
moderationReason: string | null;
createdAt: Date;
updatedAt: Date;
author: { id: number; displayName: string } | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
};
export type ThreadMessagesPage = {
items: ThreadMessage[];
beforeCursor: string | null;
hasMoreBefore: boolean;
};
export type ThreadsPage = ListPage<ThreadSummary>;
type DbClient = PoolClient;
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
@@ -8837,6 +8891,873 @@ export async function importAdminHabitatsCsv(payload: Record<string, unknown>, u
return getAdminDataToolsSummary();
}
const threadReactionTypes: ThreadReactionType[] = ['thumbs-up', 'heart', 'laugh', 'fire', 'eyes'];
const defaultThreadLimit = 20;
const maxThreadLimit = 50;
const defaultThreadMessageLimit = 30;
const maxThreadMessageLimit = 80;
type ThreadCursor = { value: string; id: number };
type ThreadMessageCursor = { createdAt: string; id: number };
function emptyThreadReactionCounts(): ThreadReactionCounts {
return { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 };
}
function isThreadReactionType(value: unknown): value is ThreadReactionType {
return typeof value === 'string' && threadReactionTypes.includes(value as ThreadReactionType);
}
function cleanThreadReactionType(value: unknown): ThreadReactionType {
if (!isThreadReactionType(value)) {
throw validationError('server.validation.reactionInvalid');
}
return value;
}
function cleanThreadLimit(value: QueryValue, fallback = defaultThreadLimit, max = maxThreadLimit): number {
const raw = Number(asString(value));
return Number.isInteger(raw) && raw > 0 ? Math.min(raw, max) : fallback;
}
function encodeCursor(value: unknown): string {
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
}
function decodeThreadCursor(value: QueryValue): ThreadCursor | null {
const cursor = asString(value);
if (!cursor) return null;
try {
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
if (typeof record.value === 'string' && Number.isInteger(Number(record.id))) {
return { value: record.value, id: Number(record.id) };
}
}
} catch {
return null;
}
return null;
}
function decodeThreadMessageCursor(value: QueryValue): ThreadMessageCursor | null {
const cursor = asString(value);
if (!cursor) return null;
try {
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
if (typeof record.createdAt === 'string' && Number.isInteger(Number(record.id))) {
return { createdAt: record.createdAt, id: Number(record.id) };
}
}
} catch {
return null;
}
return null;
}
function cleanThreadTitle(value: unknown): string {
const title = cleanName(value, 'server.validation.titleRequired');
if (title.length > 140) {
throw validationError('server.validation.valueTooLong');
}
return title;
}
function cleanThreadMessageBody(value: unknown): string {
const body = cleanName(value, 'server.validation.commentRequired');
if (body.length > 2000) {
throw validationError('server.validation.commentTooLong');
}
return body;
}
function cleanThreadLanguageCode(value: unknown): string {
const languageCode = cleanModerationLanguageCode(value);
if (!languageCode) {
throw validationError('server.validation.languageInvalid');
}
return languageCode;
}
function cleanThreadTagIds(value: unknown): number[] {
if (!Array.isArray(value)) {
return [];
}
return cleanIds(value).slice(0, 8);
}
async function publicThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
const rows = await query<{
id: number;
name: string;
allowUserThreads: boolean;
sortOrder: number;
tags: ThreadChannelTag[] | null;
languages: Array<{ code: string; name: string }> | null;
unreadCount: number;
}>(
`
SELECT
tc.id,
tc.name,
tc.allow_user_threads AS "allowUserThreads",
tc.sort_order AS "sortOrder",
COALESCE(tags.items, '[]'::json) AS tags,
COALESCE(channel_languages.items, fallback_languages.items, '[]'::json) AS languages,
CASE
WHEN $1::integer IS NULL THEN 0
ELSE COALESCE(unread.count, 0)
END AS "unreadCount"
FROM thread_channels tc
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('id', tct.id, 'name', tct.name, 'sortOrder', tct.sort_order) ORDER BY tct.sort_order, tct.id) AS items
FROM thread_channel_tags tct
WHERE tct.channel_id = tc.id
) tags ON true
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY tcl.sort_order, l.sort_order, l.code) AS items
FROM thread_channel_languages tcl
JOIN languages l ON l.code = tcl.language_code
WHERE tcl.channel_id = tc.id
AND l.enabled = true
) channel_languages ON true
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY l.sort_order, l.code) AS items
FROM languages l
WHERE l.enabled = true
) fallback_languages ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS count
FROM thread_follows tf
JOIN threads t ON t.id = tf.thread_id
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
WHERE tf.user_id = $1::integer
AND t.channel_id = tc.id
AND t.deleted_at IS NULL
AND t.last_message_id IS NOT NULL
AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)
) unread ON true
ORDER BY tc.sort_order, tc.id
`,
[userId]
);
return rows.map((row) => ({
id: row.id,
name: row.name,
allowUserThreads: row.allowUserThreads,
sortOrder: row.sortOrder,
tags: row.tags ?? [],
languages: row.languages ?? [],
unreadCount: row.unreadCount
}));
}
export async function listThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
return publicThreadChannels(userId);
}
export async function listAdminThreadChannels(): Promise<ThreadChannel[]> {
return publicThreadChannels(null);
}
async function channelAllowsLanguage(channelId: number, languageCode: string): Promise<boolean> {
const row = await queryOne<{ allowed: boolean }>(
`
SELECT CASE
WHEN EXISTS (SELECT 1 FROM thread_channel_languages WHERE channel_id = $1) THEN EXISTS (
SELECT 1
FROM thread_channel_languages tcl
JOIN languages l ON l.code = tcl.language_code
WHERE tcl.channel_id = $1 AND tcl.language_code = $2 AND l.enabled = true
)
ELSE EXISTS (SELECT 1 FROM languages WHERE code = $2 AND enabled = true)
END AS allowed
`,
[channelId, languageCode]
);
return row?.allowed === true;
}
async function validateThreadTags(channelId: number, tagIds: number[]): Promise<void> {
if (!tagIds.length) return;
const rows = await query<{ id: number }>(
'SELECT id FROM thread_channel_tags WHERE channel_id = $1 AND id = ANY($2::integer[])',
[channelId, tagIds]
);
if (rows.length !== tagIds.length) {
throw validationError('server.validation.invalidField');
}
}
async function threadReactionCounts(threadIds: number[], userId: number | null): Promise<{
counts: Map<number, ThreadReactionCounts>;
mine: Map<number, ThreadReactionType[]>;
}> {
const counts = new Map<number, ThreadReactionCounts>();
const mine = new Map<number, ThreadReactionType[]>();
for (const id of threadIds) counts.set(id, emptyThreadReactionCounts());
if (!threadIds.length) return { counts, mine };
const countRows = await query<{ threadId: number; reactionType: ThreadReactionType; count: number }>(
`
SELECT thread_id AS "threadId", reaction_type AS "reactionType", COUNT(*)::integer AS count
FROM thread_reactions
WHERE thread_id = ANY($1::integer[])
GROUP BY thread_id, reaction_type
`,
[threadIds]
);
for (const row of countRows) {
const item = counts.get(row.threadId);
if (item && isThreadReactionType(row.reactionType)) {
item[row.reactionType] = row.count;
}
}
if (userId !== null) {
const myRows = await query<{ threadId: number; reactionType: ThreadReactionType }>(
`
SELECT thread_id AS "threadId", reaction_type AS "reactionType"
FROM thread_reactions
WHERE user_id = $1
AND thread_id = ANY($2::integer[])
`,
[userId, threadIds]
);
for (const row of myRows) {
if (!isThreadReactionType(row.reactionType)) continue;
mine.set(row.threadId, [...(mine.get(row.threadId) ?? []), row.reactionType]);
}
}
return { counts, mine };
}
async function threadMessageReactionCounts(messageIds: number[], userId: number | null): Promise<{
counts: Map<number, ThreadReactionCounts>;
mine: Map<number, ThreadReactionType[]>;
}> {
const counts = new Map<number, ThreadReactionCounts>();
const mine = new Map<number, ThreadReactionType[]>();
for (const id of messageIds) counts.set(id, emptyThreadReactionCounts());
if (!messageIds.length) return { counts, mine };
const countRows = await query<{ messageId: number; reactionType: ThreadReactionType; count: number }>(
`
SELECT message_id AS "messageId", reaction_type AS "reactionType", COUNT(*)::integer AS count
FROM thread_message_reactions
WHERE message_id = ANY($1::integer[])
GROUP BY message_id, reaction_type
`,
[messageIds]
);
for (const row of countRows) {
const item = counts.get(row.messageId);
if (item && isThreadReactionType(row.reactionType)) {
item[row.reactionType] = row.count;
}
}
if (userId !== null) {
const myRows = await query<{ messageId: number; reactionType: ThreadReactionType }>(
`
SELECT message_id AS "messageId", reaction_type AS "reactionType"
FROM thread_message_reactions
WHERE user_id = $1
AND message_id = ANY($2::integer[])
`,
[userId, messageIds]
);
for (const row of myRows) {
if (!isThreadReactionType(row.reactionType)) continue;
mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]);
}
}
return { counts, mine };
}
async function hydrateThreads(rows: Array<ThreadSummary & { lastActiveCursor: string }>, userId: number | null): Promise<ThreadSummary[]> {
const ids = rows.map((row) => row.id);
const tags = await query<{ threadId: number; id: number; name: string; sortOrder: number }>(
`
SELECT ttl.thread_id AS "threadId", tct.id, tct.name, tct.sort_order AS "sortOrder"
FROM thread_tag_links ttl
JOIN thread_channel_tags tct ON tct.id = ttl.tag_id
WHERE ttl.thread_id = ANY($1::integer[])
ORDER BY tct.sort_order, tct.id
`,
[ids]
);
const tagsByThread = new Map<number, ThreadChannelTag[]>();
for (const tag of tags) {
tagsByThread.set(tag.threadId, [...(tagsByThread.get(tag.threadId) ?? []), { id: tag.id, name: tag.name, sortOrder: tag.sortOrder }]);
}
const reactions = await threadReactionCounts(ids, userId);
return rows.map((row) => ({
id: row.id,
channelId: row.channelId,
title: row.title,
languageCode: row.languageCode,
tags: tagsByThread.get(row.id) ?? [],
locked: row.locked,
messageCount: row.messageCount,
lastActiveAt: row.lastActiveAt,
createdAt: row.createdAt,
author: row.author,
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
myReactions: reactions.mine.get(row.id) ?? [],
followed: row.followed,
unread: row.unread
}));
}
export async function listThreads(paramsQuery: QueryParams, userId: number | null): Promise<ThreadsPage> {
const limit = cleanThreadLimit(paramsQuery.limit);
const channelId = optionalPositiveInteger(asString(paramsQuery.channelId), 'server.validation.invalidField');
const tagId = optionalPositiveInteger(asString(paramsQuery.tagId), 'server.validation.invalidField');
const language = asString(paramsQuery.language);
const sort = asString(paramsQuery.sort) ?? 'last-active';
const cursor = decodeThreadCursor(paramsQuery.cursor);
const conditions = ['t.deleted_at IS NULL'];
const params: unknown[] = [];
if (channelId !== null) {
params.push(channelId);
conditions.push(`t.channel_id = $${params.length}`);
}
if (tagId !== null) {
params.push(tagId);
conditions.push(`EXISTS (SELECT 1 FROM thread_tag_links ttl WHERE ttl.thread_id = t.id AND ttl.tag_id = $${params.length})`);
}
if (language && language !== 'all') {
params.push(cleanThreadLanguageCode(language));
conditions.push(`t.language_code = $${params.length}`);
}
const orderField = sort === 'latest' ? 't.created_at' : sort === 'most-discussed' ? 't.message_count' : 't.last_active_at';
const orderCursorField = sort === 'latest' ? 'created_at' : sort === 'most-discussed' ? 'message_count' : 'last_active_at';
if (cursor) {
params.push(cursor.value, cursor.id);
conditions.push(`(${orderField}, t.id) < ($${params.length - 1}::${sort === 'most-discussed' ? 'integer' : 'timestamptz'}, $${params.length}::integer)`);
}
params.push(limit + 1, userId);
const limitParam = params.length - 1;
const userParam = params.length;
const rows = await query<ThreadSummary & { lastActiveCursor: string; createdAtCursor: string; messageCountCursor: number }>(
`
SELECT
t.id,
t.channel_id AS "channelId",
t.title,
t.language_code AS "languageCode",
t.locked,
t.message_count AS "messageCount",
t.last_active_at AS "lastActiveAt",
t.last_active_at::text AS "lastActiveCursor",
t.created_at AS "createdAt",
t.created_at::text AS "createdAtCursor",
t.message_count AS "messageCountCursor",
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
($${userParam}::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
($${userParam}::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
FROM threads t
LEFT JOIN users u ON u.id = t.created_by_user_id
LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $${userParam}::integer
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $${userParam}::integer
WHERE ${conditions.join(' AND ')}
ORDER BY ${orderField} DESC, t.id DESC
LIMIT $${limitParam}
`,
params
);
const items = await hydrateThreads(rows.slice(0, limit), userId);
const last = rows.slice(0, limit).at(-1) as (typeof rows)[number] | undefined;
const nextValue = last ? (sort === 'latest' ? last.createdAtCursor : sort === 'most-discussed' ? String(last.messageCountCursor) : last.lastActiveCursor) : null;
return {
items,
nextCursor: rows.length > limit && last && nextValue ? encodeCursor({ value: nextValue, id: last.id }) : null,
hasMore: rows.length > limit
};
}
export async function getThread(threadIdValue: number, userId: number | null): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const rows = await query<ThreadSummary & { lastActiveCursor: string }>(
`
SELECT
t.id,
t.channel_id AS "channelId",
t.title,
t.language_code AS "languageCode",
t.locked,
t.message_count AS "messageCount",
t.last_active_at AS "lastActiveAt",
t.last_active_at::text AS "lastActiveCursor",
t.created_at AS "createdAt",
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
($2::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
($2::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
FROM threads t
LEFT JOIN users u ON u.id = t.created_by_user_id
LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $2::integer
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $2::integer
WHERE t.id = $1
AND t.deleted_at IS NULL
`,
[threadId, userId]
);
return (await hydrateThreads(rows, userId))[0] ?? null;
}
async function getThreadMessageById(messageId: number, userId: number | null, canViewAll = false): Promise<ThreadMessage | null> {
const rows = await query<ThreadMessage>(
`
SELECT
tm.id,
tm.thread_id AS "threadId",
tm.body,
tm.ai_moderation_status AS "moderationStatus",
tm.ai_moderation_language_code AS "moderationLanguageCode",
tm.ai_moderation_reason AS "moderationReason",
tm.created_at AS "createdAt",
tm.updated_at AS "updatedAt",
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
FROM thread_messages tm
LEFT JOIN users u ON u.id = tm.created_by_user_id
WHERE tm.id = $1
AND tm.deleted_at IS NULL
AND ${moderationVisibilitySql('tm', 'tm.created_by_user_id', userId, canViewAll)}
`,
[messageId]
);
const row = rows[0];
if (!row) return null;
const reactions = await threadMessageReactionCounts([row.id], userId);
return {
...row,
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
myReactions: reactions.mine.get(row.id) ?? []
};
}
export async function listThreadMessages(
threadIdValue: number,
paramsQuery: QueryParams,
userId: number | null,
canViewAll = false
): Promise<ThreadMessagesPage | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const thread = await getThread(threadId, userId);
if (!thread) return null;
const limit = cleanThreadLimit(paramsQuery.limit, defaultThreadMessageLimit, maxThreadMessageLimit);
const before = decodeThreadMessageCursor(paramsQuery.before);
const conditions = ['tm.thread_id = $1', 'tm.deleted_at IS NULL'];
const params: unknown[] = [threadId];
if (before) {
params.push(before.createdAt, before.id);
conditions.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}
if (!canViewAll) {
if (userId !== null) {
params.push(userId);
conditions.push(`(tm.ai_moderation_status = 'approved' OR tm.created_by_user_id = $${params.length})`);
} else {
conditions.push("tm.ai_moderation_status = 'approved'");
}
}
params.push(limit + 1);
const rows = await query<ThreadMessage & { createdAtCursor: string }>(
`
SELECT
tm.id,
tm.thread_id AS "threadId",
tm.body,
tm.ai_moderation_status AS "moderationStatus",
tm.ai_moderation_language_code AS "moderationLanguageCode",
tm.ai_moderation_reason AS "moderationReason",
tm.created_at AS "createdAt",
tm.created_at::text AS "createdAtCursor",
tm.updated_at AS "updatedAt",
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
FROM thread_messages tm
LEFT JOIN users u ON u.id = tm.created_by_user_id
WHERE ${conditions.join(' AND ')}
ORDER BY tm.created_at DESC, tm.id DESC
LIMIT $${params.length}
`,
params
);
const pageRows = rows.slice(0, limit).reverse();
const reactions = await threadMessageReactionCounts(pageRows.map((row) => row.id), userId);
const items = pageRows.map((row) => ({
id: row.id,
threadId: row.threadId,
body: row.body,
moderationStatus: row.moderationStatus,
moderationLanguageCode: row.moderationLanguageCode,
moderationReason: row.moderationReason,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
author: row.author,
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
myReactions: reactions.mine.get(row.id) ?? []
}));
const oldest = rows.slice(0, limit).at(-1);
return {
items,
beforeCursor: rows.length > limit && oldest ? encodeCursor({ createdAt: oldest.createdAtCursor, id: oldest.id }) : null,
hasMoreBefore: rows.length > limit
};
}
export async function createThread(payload: Record<string, unknown>, userId: number): Promise<ThreadSummary> {
const channelId = requirePositiveInteger(payload.channelId, 'server.validation.invalidField');
const title = cleanThreadTitle(payload.title);
const languageCode = cleanThreadLanguageCode(payload.languageCode);
const tagIds = cleanThreadTagIds(payload.tagIds);
const messageBody = cleanThreadMessageBody(payload.body);
const channel = await queryOne<{ id: number; allowUserThreads: boolean }>(
'SELECT id, allow_user_threads AS "allowUserThreads" FROM thread_channels WHERE id = $1',
[channelId]
);
if (!channel || !channel.allowUserThreads) {
throw validationError('server.validation.invalidField');
}
if (!(await channelAllowsLanguage(channelId, languageCode))) {
throw validationError('server.validation.languageInvalid');
}
await validateThreadTags(channelId, tagIds);
const ids = await withTransaction(async (client) => {
const threadResult = await client.query<{ id: number }>(
`
INSERT INTO threads (channel_id, title, language_code, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $4)
RETURNING id
`,
[channelId, title, languageCode, userId]
);
const threadId = threadResult.rows[0].id;
for (const tagId of tagIds) {
await client.query('INSERT INTO thread_tag_links (thread_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, tagId]);
}
const messageResult = await client.query<{ id: number }>(
`
INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
VALUES ($1, $2, 'unreviewed', $3, $4)
RETURNING id
`,
[threadId, messageBody, languageCode, userId]
);
await client.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
return { threadId, messageId: messageResult.rows[0].id };
});
await requestAiModerationReview({ type: 'thread-message', id: ids.messageId }, { languageCode, resetRetries: true });
return (await getThread(ids.threadId, userId)) as ThreadSummary;
}
export async function createThreadMessage(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const body = cleanThreadMessageBody(payload.body);
const thread = await queryOne<{ id: number; locked: boolean; languageCode: string }>(
'SELECT id, locked, language_code AS "languageCode" FROM threads WHERE id = $1 AND deleted_at IS NULL',
[threadId]
);
if (!thread) return null;
if (thread.locked) {
throw validationError('server.validation.invalidField');
}
const result = await queryOne<{ id: number }>(
`
INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
VALUES ($1, $2, 'unreviewed', $3, $4)
RETURNING id
`,
[threadId, body, thread.languageCode, userId]
);
if (!result) return null;
await requestAiModerationReview({ type: 'thread-message', id: result.id }, { languageCode: thread.languageCode, resetRetries: true });
return getThreadMessageById(result.id, userId, false);
}
export async function markThreadRead(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const row = await queryOne<{ lastMessageId: number | null }>(
'SELECT last_message_id AS "lastMessageId" FROM threads WHERE id = $1 AND deleted_at IS NULL',
[threadId]
);
if (!row) return null;
await pool.query(
`
INSERT INTO thread_reads (thread_id, user_id, last_read_message_id, last_read_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (thread_id, user_id)
DO UPDATE SET last_read_message_id = EXCLUDED.last_read_message_id,
last_read_at = now()
`,
[threadId, userId, row.lastMessageId]
);
const thread = await getThread(threadId, userId);
await publishThreadReadUpdated(userId, threadId, thread?.unread ?? false, 0);
return thread;
}
export async function followThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const thread = await getThread(threadId, userId);
if (!thread) return null;
await pool.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
return getThread(threadId, userId);
}
export async function unfollowThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const thread = await getThread(threadId, userId);
if (!thread) return null;
await pool.query('DELETE FROM thread_follows WHERE thread_id = $1 AND user_id = $2', [threadId, userId]);
return getThread(threadId, userId);
}
export async function setThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const reactionType = cleanThreadReactionType(payload.reactionType);
const thread = await getThread(threadId, userId);
if (!thread) return null;
await pool.query(
`
INSERT INTO thread_reactions (thread_id, user_id, reaction_type)
VALUES ($1, $2, $3)
ON CONFLICT (thread_id, user_id, reaction_type)
DO UPDATE SET updated_at = now()
`,
[threadId, userId, reactionType]
);
const updated = await getThread(threadId, userId);
if (updated) {
await publishThreadReactionUpdated(userId, {
type: 'thread.reactions.updated',
target: 'thread',
threadId,
messageId: null,
reactionCounts: updated.reactionCounts,
myReactions: updated.myReactions
});
}
return updated;
}
export async function deleteThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const reactionType = cleanThreadReactionType(payload.reactionType);
const thread = await getThread(threadId, userId);
if (!thread) return null;
await pool.query('DELETE FROM thread_reactions WHERE thread_id = $1 AND user_id = $2 AND reaction_type = $3', [threadId, userId, reactionType]);
return getThread(threadId, userId);
}
export async function setThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
const reactionType = cleanThreadReactionType(payload.reactionType);
const message = await getThreadMessageById(messageId, userId);
if (!message || message.moderationStatus !== 'approved') return null;
await pool.query(
`
INSERT INTO thread_message_reactions (message_id, user_id, reaction_type)
VALUES ($1, $2, $3)
ON CONFLICT (message_id, user_id, reaction_type)
DO UPDATE SET updated_at = now()
`,
[messageId, userId, reactionType]
);
const updated = await getThreadMessageById(messageId, userId);
if (updated) {
await publishThreadReactionUpdated(userId, {
type: 'thread.reactions.updated',
target: 'message',
threadId: updated.threadId,
messageId,
reactionCounts: updated.reactionCounts,
myReactions: updated.myReactions
});
}
return updated;
}
export async function deleteThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
const reactionType = cleanThreadReactionType(payload.reactionType);
const message = await getThreadMessageById(messageId, userId);
if (!message) return null;
await pool.query('DELETE FROM thread_message_reactions WHERE message_id = $1 AND user_id = $2 AND reaction_type = $3', [messageId, userId, reactionType]);
return getThreadMessageById(messageId, userId);
}
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
const row = await queryOne<{ threadId: number }>(
`
UPDATE threads t
SET last_message_id = tm.id,
message_count = (
SELECT COUNT(*)::integer
FROM thread_messages visible_message
WHERE visible_message.thread_id = t.id
AND visible_message.deleted_at IS NULL
AND visible_message.ai_moderation_status = 'approved'
),
last_active_at = GREATEST(t.last_active_at, tm.created_at),
updated_at = now()
FROM thread_messages tm
WHERE tm.id = $1
AND tm.thread_id = t.id
AND tm.deleted_at IS NULL
AND tm.ai_moderation_status = 'approved'
RETURNING t.id AS "threadId"
`,
[messageId]
);
if (!row) return;
const message = await getThreadMessageById(messageId, null, true);
const thread = await getThread(row.threadId, null);
if (message && thread) {
await publishThreadMessageCreated(thread, message);
} else {
await publishThreadMessageModeration(row.threadId, message);
}
}
export async function createAdminThreadChannel(payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[]> {
const name = cleanName(payload.name, 'server.validation.nameRequired');
const allowUserThreads = payload.allowUserThreads !== false;
const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'thread_channels');
const result = await client.query<{ id: number }>(
`
INSERT INTO thread_channels (name, allow_user_threads, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $4)
RETURNING id
`,
[name, allowUserThreads, sortOrder, userId]
);
await replaceThreadChannelConfig(client, result.rows[0].id, tagNames, languageCodes);
});
return listAdminThreadChannels();
}
async function replaceThreadChannelConfig(client: DbClient, channelId: number, tagNames: string[], languageCodes: string[]): Promise<void> {
await client.query('DELETE FROM thread_channel_tags WHERE channel_id = $1', [channelId]);
await client.query('DELETE FROM thread_channel_languages WHERE channel_id = $1', [channelId]);
for (const [index, tagName] of [...new Set(tagNames)].entries()) {
await client.query('INSERT INTO thread_channel_tags (channel_id, name, sort_order) VALUES ($1, $2, $3)', [channelId, tagName, (index + 1) * 10]);
}
for (const [index, languageCode] of [...new Set(languageCodes)].entries()) {
await client.query('INSERT INTO thread_channel_languages (channel_id, language_code, sort_order) VALUES ($1, $2, $3)', [
channelId,
languageCode,
(index + 1) * 10
]);
}
}
export async function updateAdminThreadChannel(channelIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[] | null> {
const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
const name = cleanName(payload.name, 'server.validation.nameRequired');
const allowUserThreads = payload.allowUserThreads !== false;
const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
UPDATE thread_channels
SET name = $1, allow_user_threads = $2, updated_by_user_id = $3, updated_at = now()
WHERE id = $4
`,
[name, allowUserThreads, userId, channelId]
);
if (!result.rowCount) return false;
await replaceThreadChannelConfig(client, channelId, tagNames, languageCodes);
return true;
});
return updated ? listAdminThreadChannels() : null;
}
export async function deleteAdminThreadChannel(channelIdValue: number): Promise<boolean> {
const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
const result = await pool.query('DELETE FROM thread_channels WHERE id = $1', [channelId]);
return Boolean(result.rowCount);
}
export async function updateThreadLock(threadIdValue: number, locked: boolean, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const result = await pool.query(
'UPDATE threads SET locked = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 AND deleted_at IS NULL',
[locked, userId, threadId]
);
return result.rowCount ? getThread(threadId, userId) : null;
}
export async function deleteThread(threadIdValue: number, userId: number): Promise<boolean> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const result = await pool.query(
'UPDATE threads SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL',
[userId, threadId]
);
return Boolean(result.rowCount);
}
export async function deleteThreadMessage(messageIdValue: number, userId: number): Promise<boolean> {
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
const result = await pool.query<{ threadId: number }>(
`
UPDATE thread_messages
SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now()
WHERE id = $2
AND deleted_at IS NULL
RETURNING thread_id AS "threadId"
`,
[userId, messageId]
);
if (!result.rowCount) return false;
await pool.query(
`
UPDATE threads t
SET message_count = (
SELECT COUNT(*)::integer
FROM thread_messages tm
WHERE tm.thread_id = t.id
AND tm.deleted_at IS NULL
AND tm.ai_moderation_status = 'approved'
),
last_message_id = (
SELECT tm.id
FROM thread_messages tm
WHERE tm.thread_id = t.id
AND tm.deleted_at IS NULL
AND tm.ai_moderation_status = 'approved'
ORDER BY tm.created_at DESC, tm.id DESC
LIMIT 1
),
updated_at = now()
WHERE t.id = $1
`,
[result.rows[0].threadId]
);
return true;
}
export async function createThreadsWsTicketForUser(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
return createThreadWebSocketTicket(userId);
}
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
const scopes = cleanDataToolScopes(payload.scopes);
await withTransaction(async (client) => {

View File

@@ -50,8 +50,13 @@ import {
createLifePost,
createPokemon,
createRecipe,
createAdminThreadChannel,
createThread,
createThreadMessage,
createThreadsWsTicketForUser,
deleteConfig,
deleteAncientArtifact,
deleteAdminThreadChannel,
deleteDailyChecklistItem,
deleteDish,
deleteDishCategory,
@@ -67,10 +72,15 @@ import {
deleteLifePostReaction,
deletePokemon,
deleteRecipe,
deleteThread,
deleteThreadMessage,
deleteThreadMessageReaction,
deleteThreadReaction,
exportAdminData,
fetchPokemonData,
fetchPokemonImageOptions,
followUser,
followThread,
getAdminDataToolsSummary,
getAncientArtifact,
getHabitat,
@@ -81,6 +91,7 @@ import {
getPokemon,
getPublicUserProfile,
getRecipe,
getThread,
globalSearch,
importAdminData,
importAdminHabitatsCsv,
@@ -88,6 +99,7 @@ import {
isConfigType,
listAncientArtifacts,
listEntityDiscussionComments,
listAdminThreadChannels,
listConfig,
listDailyChecklistItems,
listHabitats,
@@ -100,6 +112,9 @@ import {
listPokemon,
listPokemonFetchOptions,
listRecipes,
listThreadChannels,
listThreadMessages,
listThreads,
listUserCommentActivities,
listUserLifePosts,
listUserReactionActivities,
@@ -112,6 +127,7 @@ import {
reorderItems,
reorderLanguages,
reorderRecipes,
markThreadRead,
retryEntityDiscussionCommentModeration,
retryLifeCommentModeration,
retryLifePostModeration,
@@ -120,6 +136,8 @@ import {
setLifePostReaction,
setEntityDiscussionCommentLike,
setLifeCommentLike,
setThreadMessageReaction,
setThreadReaction,
updateConfig,
updateAncientArtifact,
updateDailyChecklistItem,
@@ -131,7 +149,10 @@ import {
updateLifePost,
updatePokemon,
updateRecipe,
updateAdminThreadChannel,
updateThreadLock,
unfollowUser,
unfollowThread,
wipeAdminData
} from './queries.ts';
import {
@@ -160,6 +181,7 @@ import {
markNotificationRead,
setupNotificationWebSocketServer
} from './notifications.ts';
import { setupThreadWebSocketServer } from './threadsRealtime.ts';
const app = Fastify({
logger: true,
@@ -1689,6 +1711,129 @@ app.delete('/api/discussions/comments/:id/like', async (request, reply) => {
return comment ? comment : notFound(reply, request);
});
app.get('/api/thread-channels', async (request) => {
const user = await optionalUser(request);
return listThreadChannels(user?.id ?? null);
});
app.get('/api/threads', async (request) => {
const user = await optionalUser(request);
return listThreads(request.query as Record<string, string | string[] | undefined>, user?.id ?? null);
});
app.post('/api/threads/ws-ticket', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
return createThreadsWsTicketForUser(user.id);
});
app.post('/api/threads', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.create', 'communityWrite');
return user ? reply.code(201).send(await createThread(request.body as Record<string, unknown>, user.id)) : undefined;
});
app.get('/api/threads/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const thread = await getThread(Number(id), user?.id ?? null);
return thread ? thread : notFound(reply, request);
});
app.get('/api/threads/:id/messages', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const canViewAll = user ? userHasPermission(user, 'admin.threads.messages.delete') : false;
const messages = await listThreadMessages(
Number(id),
request.query as Record<string, string | string[] | undefined>,
user?.id ?? null,
canViewAll
);
return messages ? messages : notFound(reply, request);
});
app.post('/api/threads/:id/messages', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.messages.create', 'communityWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const message = await createThreadMessage(Number(id), request.body as Record<string, unknown>, user.id);
return message ? reply.code(201).send(message) : notFound(reply, request);
});
app.put('/api/threads/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await followThread(Number(id), user.id);
return thread ? thread : notFound(reply, request);
});
app.delete('/api/threads/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await unfollowThread(Number(id), user.id);
return thread ? thread : notFound(reply, request);
});
app.post('/api/threads/:id/read', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await markThreadRead(Number(id), user.id);
return thread ? thread : notFound(reply, request);
});
app.put('/api/threads/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await setThreadReaction(Number(id), request.body as Record<string, unknown>, user.id);
return thread ? thread : notFound(reply, request);
});
app.delete('/api/threads/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await deleteThreadReaction(Number(id), request.body as Record<string, unknown>, user.id);
return thread ? thread : notFound(reply, request);
});
app.put('/api/thread-messages/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const message = await setThreadMessageReaction(Number(id), request.body as Record<string, unknown>, user.id);
return message ? message : notFound(reply, request);
});
app.delete('/api/thread-messages/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const message = await deleteThreadMessageReaction(Number(id), request.body as Record<string, unknown>, user.id);
return message ? message : notFound(reply, request);
});
app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
@@ -2215,6 +2360,69 @@ app.post('/api/admin/data-tools/wipe', async (request, reply) => {
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
});
app.get('/api/admin/thread-channels', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.threads.channels.read');
return user ? listAdminThreadChannels() : undefined;
});
app.post('/api/admin/thread-channels', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.create', 'adminWrite');
return user
? reply.code(201).send(await createAdminThreadChannel(request.body as Record<string, unknown>, user.id))
: undefined;
});
app.put('/api/admin/thread-channels/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.update', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const channels = await updateAdminThreadChannel(Number(id), request.body as Record<string, unknown>, user.id);
return channels ? channels : notFound(reply, request);
});
app.delete('/api/admin/thread-channels/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.delete', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteAdminThreadChannel(Number(id));
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.put('/api/admin/threads/:id/lock', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.threads.lock', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const payload = request.body as Record<string, unknown>;
const thread = await updateThreadLock(Number(id), payload.locked === true, user.id);
return thread ? thread : notFound(reply, request);
});
app.delete('/api/admin/threads/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.threads.delete', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteThread(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.delete('/api/admin/thread-messages/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.messages.delete', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteThreadMessage(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.config.read');
if (!user) {
@@ -2286,6 +2494,7 @@ try {
await syncSystemWordingCatalog();
await startAiModerationWorker(app.log);
setupNotificationWebSocketServer(app.server, app.log);
setupThreadWebSocketServer(app.server, app.log);
await app.listen({ host: '0.0.0.0', port });
} catch (error) {
app.log.error(error);

View File

@@ -0,0 +1,421 @@
import type { FastifyBaseLogger } from 'fastify';
import { Buffer } from 'node:buffer';
import { createHash, randomBytes } from 'node:crypto';
import type { Server } from 'node:http';
import type { Duplex } from 'node:stream';
import { pool, query, queryOne } from './db.ts';
import type { ThreadMessage, ThreadReactionCounts, ThreadReactionType, ThreadSummary } from './queries.ts';
export type ThreadWsMessage =
| { type: 'threads.connected'; followedUnreadCount: number }
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
| { type: 'thread.message.moderation'; threadId: number; message: ThreadMessage | null }
| {
type: 'thread.reactions.updated';
target: 'thread' | 'message';
threadId: number;
messageId: number | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
}
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const websocketTicketMinutes = 2;
const threadClients = new Map<number, Set<Duplex>>();
const clientUsers = new WeakMap<Duplex, number>();
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export async function createThreadWebSocketTicket(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
const ticket = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + websocketTicketMinutes * 60_000);
await pool.query(
`
INSERT INTO thread_ws_tickets (ticket_hash, user_id, expires_at)
VALUES ($1, $2, $3)
`,
[hashToken(ticket), userId, expiresAt]
);
await pool.query('DELETE FROM thread_ws_tickets WHERE expires_at < now()');
return { ticket, expiresAt };
}
async function consumeThreadWebSocketTicket(ticket: string): Promise<number | null> {
if (!ticket) {
return null;
}
const row = await queryOne<{ userId: number }>(
`
DELETE FROM thread_ws_tickets
WHERE ticket_hash = $1
AND expires_at > now()
RETURNING user_id AS "userId"
`,
[hashToken(ticket)]
);
return row?.userId ?? null;
}
async function followedUnreadCount(userId: number): Promise<number> {
const row = await queryOne<{ count: number }>(
`
SELECT COUNT(*)::integer AS count
FROM thread_follows tf
JOIN threads t ON t.id = tf.thread_id
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
WHERE tf.user_id = $1
AND t.deleted_at IS NULL
AND t.last_message_id IS NOT NULL
AND (
tr.last_read_message_id IS NULL
OR t.last_message_id > tr.last_read_message_id
)
`,
[userId]
);
return row?.count ?? 0;
}
function wsFrame(data: Buffer, opcode = 0x1): Buffer {
const length = data.byteLength;
if (length < 126) {
return Buffer.concat([Buffer.from([0x80 | opcode, length]), data]);
}
if (length < 65536) {
const header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(length, 2);
return Buffer.concat([header, data]);
}
const header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
return Buffer.concat([header, data]);
}
function sendWsJson(socket: Duplex, message: ThreadWsMessage): void {
if (!socket.destroyed) {
socket.write(wsFrame(Buffer.from(JSON.stringify(message), 'utf8')));
}
}
function websocketPayload(buffer: Buffer): { opcode: number; payload: Buffer } | null {
if (buffer.byteLength < 2) {
return null;
}
const opcode = buffer[0] & 0x0f;
const masked = (buffer[1] & 0x80) !== 0;
let length = buffer[1] & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.byteLength < offset + 2) return null;
length = buffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (buffer.byteLength < offset + 8) return null;
const longLength = buffer.readBigUInt64BE(offset);
if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) return null;
length = Number(longLength);
offset += 8;
}
let mask: Buffer | null = null;
if (masked) {
if (buffer.byteLength < offset + 4) return null;
mask = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.byteLength < offset + length) {
return null;
}
const payload = Buffer.from(buffer.subarray(offset, offset + length));
if (mask) {
for (let index = 0; index < payload.byteLength; index += 1) {
payload[index] ^= mask[index % 4];
}
}
return { opcode, payload };
}
function closeSocket(socket: Duplex, statusCode = 1000): void {
if (socket.destroyed) {
return;
}
const payload = Buffer.alloc(2);
payload.writeUInt16BE(statusCode, 0);
socket.end(wsFrame(payload, 0x8));
}
function rejectUpgrade(socket: Duplex, statusCode: number, statusText: string): void {
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\n\r\n`);
socket.destroy();
}
function addThreadClient(userId: number, socket: Duplex): void {
clientUsers.set(socket, userId);
let clients = threadClients.get(userId);
if (!clients) {
clients = new Set();
threadClients.set(userId, clients);
}
clients.add(socket);
socket.on('close', () => {
clients?.delete(socket);
if (clients?.size === 0) {
threadClients.delete(userId);
}
});
}
async function recipientUserIds(threadId: number): Promise<number[]> {
const rows = await query<{ userId: number }>(
`
SELECT DISTINCT user_id AS "userId"
FROM thread_follows
WHERE thread_id = $1
`,
[threadId]
);
return rows.map((row) => row.userId);
}
function connectedUserIds(): number[] {
return [...threadClients.keys()];
}
async function publishToUsers(userIds: number[], message: ThreadWsMessage): Promise<void> {
for (const userId of userIds) {
const clients = threadClients.get(userId);
if (!clients) {
continue;
}
for (const socket of clients) {
sendWsJson(socket, message);
}
}
}
export async function publishThreadMessageCreated(thread: ThreadSummary, message: ThreadMessage): Promise<void> {
const users = [...new Set([...(await recipientUserIds(thread.id)), ...connectedUserIds()])];
if (message.author?.id && !users.includes(message.author.id)) {
users.push(message.author.id);
}
await publishToUsers(users, {
type: 'thread.message.created',
threadId: thread.id,
message,
thread
});
}
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
const row = await queryOne<{
threadId: number;
channelId: number;
title: string;
languageCode: string;
locked: boolean;
messageCount: number;
lastActiveAt: Date;
threadCreatedAt: Date;
threadAuthor: { id: number; displayName: string } | null;
messageBody: string;
moderationStatus: ThreadMessage['moderationStatus'];
moderationLanguageCode: string | null;
moderationReason: string | null;
messageCreatedAt: Date;
messageUpdatedAt: Date;
messageAuthor: { id: number; displayName: string } | null;
}>(
`
WITH updated_thread AS (
UPDATE threads t
SET last_message_id = tm.id,
message_count = (
SELECT COUNT(*)::integer
FROM thread_messages visible_message
WHERE visible_message.thread_id = t.id
AND visible_message.deleted_at IS NULL
AND visible_message.ai_moderation_status = 'approved'
),
last_active_at = GREATEST(t.last_active_at, tm.created_at),
updated_at = now()
FROM thread_messages tm
WHERE tm.id = $1
AND tm.thread_id = t.id
AND tm.deleted_at IS NULL
AND tm.ai_moderation_status = 'approved'
RETURNING
t.id,
t.channel_id,
t.title,
t.language_code,
t.locked,
t.message_count,
t.last_active_at,
t.created_at,
t.created_by_user_id
)
SELECT
ut.id AS "threadId",
ut.channel_id AS "channelId",
ut.title,
ut.language_code AS "languageCode",
ut.locked,
ut.message_count AS "messageCount",
ut.last_active_at AS "lastActiveAt",
ut.created_at AS "threadCreatedAt",
CASE WHEN thread_user.id IS NULL THEN NULL ELSE json_build_object('id', thread_user.id, 'displayName', thread_user.display_name) END AS "threadAuthor",
tm.body AS "messageBody",
tm.ai_moderation_status AS "moderationStatus",
tm.ai_moderation_language_code AS "moderationLanguageCode",
tm.ai_moderation_reason AS "moderationReason",
tm.created_at AS "messageCreatedAt",
tm.updated_at AS "messageUpdatedAt",
CASE WHEN message_user.id IS NULL THEN NULL ELSE json_build_object('id', message_user.id, 'displayName', message_user.display_name) END AS "messageAuthor"
FROM updated_thread ut
JOIN thread_messages tm ON tm.id = $1
LEFT JOIN users thread_user ON thread_user.id = ut.created_by_user_id
LEFT JOIN users message_user ON message_user.id = tm.created_by_user_id
`,
[messageId]
);
if (!row) {
return;
}
await publishThreadMessageCreated(
{
id: row.threadId,
channelId: row.channelId,
title: row.title,
languageCode: row.languageCode,
tags: [],
locked: row.locked,
messageCount: row.messageCount,
lastActiveAt: row.lastActiveAt,
createdAt: row.threadCreatedAt,
author: row.threadAuthor,
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
myReactions: [],
followed: true,
unread: true
},
{
id: messageId,
threadId: row.threadId,
body: row.messageBody,
moderationStatus: row.moderationStatus,
moderationLanguageCode: row.moderationLanguageCode,
moderationReason: row.moderationReason,
createdAt: row.messageCreatedAt,
updatedAt: row.messageUpdatedAt,
author: row.messageAuthor,
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
myReactions: []
}
);
}
export async function publishThreadMessageModeration(threadId: number, message: ThreadMessage | null): Promise<void> {
await publishToUsers([...new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()])], {
type: 'thread.message.moderation',
threadId,
message
});
}
export async function publishThreadReactionUpdated(
userId: number,
message: Extract<ThreadWsMessage, { type: 'thread.reactions.updated' }>
): Promise<void> {
const users = await recipientUserIds(message.threadId);
for (const connectedUserId of connectedUserIds()) {
if (!users.includes(connectedUserId)) {
users.push(connectedUserId);
}
}
if (!users.includes(userId)) {
users.push(userId);
}
await publishToUsers(users, message);
}
export async function publishThreadReadUpdated(userId: number, threadId: number, unread: boolean, unreadCount: number): Promise<void> {
await publishToUsers([userId], { type: 'thread.read.updated', threadId, unread, unreadCount });
}
export function setupThreadWebSocketServer(server: Server, logger: FastifyBaseLogger): void {
server.on('upgrade', async (request, socket) => {
const url = new URL(request.url ?? '/', 'http://localhost');
if (url.pathname !== '/api/threads/ws') {
return;
}
const key = request.headers['sec-websocket-key'];
if (request.method !== 'GET' || typeof key !== 'string' || key.trim() === '') {
rejectUpgrade(socket, 400, 'Bad Request');
return;
}
try {
const ticket = url.searchParams.get('ticket') ?? '';
const userId = await consumeThreadWebSocketTicket(ticket);
if (!userId) {
rejectUpgrade(socket, 401, 'Unauthorized');
return;
}
const accept = createHash('sha1').update(`${key}${websocketGuid}`).digest('base64');
socket.write(
[
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${accept}`,
'\r\n'
].join('\r\n')
);
addThreadClient(userId, socket);
sendWsJson(socket, {
type: 'threads.connected',
followedUnreadCount: await followedUnreadCount(userId)
});
socket.on('data', (buffer: Buffer) => {
const frame = websocketPayload(buffer);
if (!frame) {
return;
}
if (frame.opcode === 0x8) {
closeSocket(socket);
} else if (frame.opcode === 0x9) {
socket.write(wsFrame(frame.payload, 0x0a));
}
});
socket.on('error', () => {
socket.destroy();
});
} catch (error) {
logger.warn({ err: error }, 'Thread WebSocket upgrade failed');
rejectUpgrade(socket, 500, 'Internal Server Error');
}
});
}