feat(threads): add editing, moderation retry, and emoji reactions

Add API routes and UI for editing threads and messages
Allow users to retry AI moderation for failed messages
Migrate thread reactions to use native emojis
Implement frontend search filtering for thread list
This commit is contained in:
2026-05-07 13:30:13 +08:00
parent cbb101336b
commit 64ca494d82
10 changed files with 829 additions and 130 deletions

View File

@@ -987,24 +987,52 @@ CREATE INDEX IF NOT EXISTS thread_messages_user_idx
CREATE TABLE IF NOT EXISTS thread_reactions (
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reaction_type text NOT NULL CHECK (reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes')),
reaction_type text NOT NULL CHECK (length(reaction_type) BETWEEN 1 AND 24),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (thread_id, user_id, reaction_type)
);
ALTER TABLE thread_reactions
DROP CONSTRAINT IF EXISTS thread_reactions_reaction_type_check,
ADD CONSTRAINT thread_reactions_reaction_type_check CHECK (length(reaction_type) BETWEEN 1 AND 24);
UPDATE thread_reactions
SET reaction_type = CASE reaction_type
WHEN 'thumbs-up' THEN '👍'
WHEN 'heart' THEN '❤️'
WHEN 'laugh' THEN '😂'
WHEN 'fire' THEN '🔥'
WHEN 'eyes' THEN '👀'
ELSE reaction_type
END
WHERE reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes');
CREATE INDEX IF NOT EXISTS thread_reactions_thread_idx
ON thread_reactions(thread_id, reaction_type);
CREATE TABLE IF NOT EXISTS thread_message_reactions (
message_id integer NOT NULL REFERENCES thread_messages(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reaction_type text NOT NULL CHECK (reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes')),
reaction_type text NOT NULL CHECK (length(reaction_type) BETWEEN 1 AND 24),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id, reaction_type)
);
ALTER TABLE thread_message_reactions
DROP CONSTRAINT IF EXISTS thread_message_reactions_reaction_type_check,
ADD CONSTRAINT thread_message_reactions_reaction_type_check CHECK (length(reaction_type) BETWEEN 1 AND 24);
UPDATE thread_message_reactions
SET reaction_type = CASE reaction_type
WHEN 'thumbs-up' THEN '👍'
WHEN 'heart' THEN '❤️'
WHEN 'laugh' THEN '😂'
WHEN 'fire' THEN '🔥'
WHEN 'eyes' THEN '👀'
ELSE reaction_type
END
WHERE reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes');
CREATE INDEX IF NOT EXISTS thread_message_reactions_message_idx
ON thread_message_reactions(message_id, reaction_type);

View File

@@ -772,12 +772,47 @@ async function updateTargetStatus(
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',
const row = await queryOne<{
threadId: number;
body: string;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
moderationReason: string | null;
createdAt: Date;
updatedAt: Date;
author: { id: number; displayName: string } | null;
}>(
`
SELECT
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
`,
[target.id]
);
if (row) {
await publishThreadMessageModeration(row.threadId, null);
await publishThreadMessageModeration(row.threadId, target.id, {
id: target.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: {},
myReactions: []
});
}
}
return;

View File

@@ -33,8 +33,8 @@ 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 ThreadReactionType = string;
export type ThreadReactionCounts = Record<string, number>;
export type ThreadChannelTag = { id: number; name: string; sortOrder: number };
export type ThreadChannel = {
id: number;
@@ -1154,6 +1154,12 @@ function validationError(message: string): ValidationError {
return error;
}
function forbiddenError(): ValidationError {
const error = new Error('server.errors.permissionDenied') as ValidationError;
error.statusCode = 403;
return error;
}
function requirePositiveInteger(value: unknown, message: string): number {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue <= 0) {
@@ -8891,28 +8897,31 @@ 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;
const threadEmojiReactionPattern = /(?:\p{Extended_Pictographic}|\p{Regional_Indicator})/u;
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);
return {};
}
function cleanThreadReactionType(value: unknown): ThreadReactionType {
if (!isThreadReactionType(value)) {
const reactionType = typeof value === 'string' ? value.trim() : '';
if (
!reactionType ||
reactionType.length > 24 ||
/\s/.test(reactionType) ||
/[\p{Letter}\p{Number}]/u.test(reactionType) ||
!threadEmojiReactionPattern.test(reactionType)
) {
throw validationError('server.validation.reactionInvalid');
}
return value;
return reactionType;
}
function cleanThreadLimit(value: QueryValue, fallback = defaultThreadLimit, max = maxThreadLimit): number {
@@ -9113,7 +9122,7 @@ async function threadReactionCounts(threadIds: number[], userId: number | null):
);
for (const row of countRows) {
const item = counts.get(row.threadId);
if (item && isThreadReactionType(row.reactionType)) {
if (item) {
item[row.reactionType] = row.count;
}
}
@@ -9129,7 +9138,6 @@ async function threadReactionCounts(threadIds: number[], userId: number | null):
[userId, threadIds]
);
for (const row of myRows) {
if (!isThreadReactionType(row.reactionType)) continue;
mine.set(row.threadId, [...(mine.get(row.threadId) ?? []), row.reactionType]);
}
}
@@ -9157,7 +9165,7 @@ async function threadMessageReactionCounts(messageIds: number[], userId: number
);
for (const row of countRows) {
const item = counts.get(row.messageId);
if (item && isThreadReactionType(row.reactionType)) {
if (item) {
item[row.reactionType] = row.count;
}
}
@@ -9173,7 +9181,6 @@ async function threadMessageReactionCounts(messageIds: number[], userId: number
[userId, messageIds]
);
for (const row of myRows) {
if (!isThreadReactionType(row.reactionType)) continue;
mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]);
}
}
@@ -9464,6 +9471,68 @@ export async function createThread(payload: Record<string, unknown>, userId: num
return (await getThread(ids.threadId, userId)) as ThreadSummary;
}
export async function updateThread(
threadIdValue: number,
payload: Record<string, unknown>,
userId: number,
canUpdateAny = false
): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const title = cleanThreadTitle(payload.title);
const tagIds = cleanThreadTagIds(payload.tagIds);
const thread = await queryOne<{ id: number; channelId: number; createdByUserId: number }>(
`
SELECT id, channel_id AS "channelId", created_by_user_id AS "createdByUserId"
FROM threads
WHERE id = $1
AND deleted_at IS NULL
`,
[threadId]
);
if (!thread) return null;
if (!canUpdateAny && thread.createdByUserId !== userId) {
throw forbiddenError();
}
await validateThreadTags(thread.channelId, tagIds);
await withTransaction(async (client) => {
await client.query('UPDATE threads SET title = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3', [title, userId, threadId]);
await client.query('DELETE FROM thread_tag_links WHERE thread_id = $1', [threadId]);
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]);
}
});
return getThread(threadId, userId);
}
async function refreshThreadMessageAggregates(threadId: number): Promise<void> {
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
`,
[threadId]
);
}
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);
@@ -9488,6 +9557,87 @@ export async function createThreadMessage(threadIdValue: number, payload: Record
return getThreadMessageById(result.id, userId, false);
}
export async function updateThreadMessage(
messageIdValue: number,
payload: Record<string, unknown>,
userId: number,
canUpdateAny = false
): Promise<ThreadMessage | null> {
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
const body = cleanThreadMessageBody(payload.body);
const message = await queryOne<{ id: number; threadId: number; languageCode: string; createdByUserId: number }>(
`
SELECT
tm.id,
tm.thread_id AS "threadId",
t.language_code AS "languageCode",
tm.created_by_user_id AS "createdByUserId"
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
`,
[messageId]
);
if (!message) return null;
if (!canUpdateAny && message.createdByUserId !== userId) {
throw forbiddenError();
}
const result = await queryOne<{ id: number }>(
`
UPDATE thread_messages
SET body = $1,
ai_moderation_status = 'reviewing',
ai_moderation_language_code = NULL,
ai_moderation_reason = NULL,
ai_moderation_content_hash = NULL,
ai_moderation_checked_at = NULL,
ai_moderation_retry_count = 0,
ai_moderation_updated_at = now(),
updated_at = now()
WHERE id = $2
AND deleted_at IS NULL
RETURNING id
`,
[body, messageId]
);
if (!result) return null;
await refreshThreadMessageAggregates(message.threadId);
await publishThreadMessageModeration(message.threadId, messageId, null);
await requestAiModerationReview({ type: 'thread-message', id: messageId }, { languageCode: message.languageCode, resetRetries: true });
return getThreadMessageById(messageId, userId, canUpdateAny);
}
export async function retryThreadMessageModeration(
messageIdValue: number,
userId: number,
canRetryAny = false
): Promise<ThreadMessage | null> {
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
const message = await queryOne<{ id: number; createdByUserId: number }>(
`
SELECT tm.id, tm.created_by_user_id AS "createdByUserId"
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
AND tm.ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
`,
[messageId]
);
if (!message) return null;
if (!canRetryAny && message.createdByUserId !== userId) {
throw forbiddenError();
}
await requestAiModerationReview({ type: 'thread-message', id: messageId }, { incrementRetries: true });
return getThreadMessageById(messageId, userId, canRetryAny);
}
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 }>(
@@ -9560,7 +9710,18 @@ export async function deleteThreadReaction(threadIdValue: number, payload: Recor
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);
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 setThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
@@ -9597,7 +9758,18 @@ export async function deleteThreadMessageReaction(messageIdValue: number, payloa
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);
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 applyApprovedThreadMessage(messageId: number): Promise<void> {
@@ -9629,7 +9801,7 @@ export async function applyApprovedThreadMessage(messageId: number): Promise<voi
if (message && thread) {
await publishThreadMessageCreated(thread, message);
} else {
await publishThreadMessageModeration(row.threadId, message);
await publishThreadMessageModeration(row.threadId, messageId, message);
}
}

View File

@@ -131,6 +131,7 @@ import {
retryEntityDiscussionCommentModeration,
retryLifeCommentModeration,
retryLifePostModeration,
retryThreadMessageModeration,
restoreLifeComment,
setLifePostRating,
setLifePostReaction,
@@ -150,7 +151,9 @@ import {
updatePokemon,
updateRecipe,
updateAdminThreadChannel,
updateThread,
updateThreadLock,
updateThreadMessage,
unfollowUser,
unfollowThread,
wipeAdminData
@@ -1734,6 +1737,17 @@ app.post('/api/threads', async (request, reply) => {
return user ? reply.code(201).send(await createThread(request.body as Record<string, unknown>, user.id)) : undefined;
});
app.put('/api/threads/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user || !(await enforceUserRateLimits(request, reply, user, 'communityWrite'))) {
return;
}
const { id } = request.params as { id: string };
const canUpdateAny = userHasPermission(user, 'admin.threads.threads.lock') || userHasPermission(user, 'admin.threads.threads.delete');
const thread = await updateThread(Number(id), request.body as Record<string, unknown>, user.id, canUpdateAny);
return thread ? thread : notFound(reply, request);
});
app.get('/api/threads/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
@@ -1764,6 +1778,38 @@ app.post('/api/threads/:id/messages', async (request, reply) => {
return message ? reply.code(201).send(message) : notFound(reply, request);
});
app.put('/api/thread-messages/:id', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['threads.messages.create', 'admin.threads.messages.delete'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const canUpdateAny = userHasPermission(user, 'admin.threads.messages.delete');
const message = await updateThreadMessage(Number(id), request.body as Record<string, unknown>, user.id, canUpdateAny);
return message ? message : notFound(reply, request);
});
app.post('/api/thread-messages/:id/moderation/retry', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['threads.messages.create', 'admin.threads.messages.delete'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const canRetryAny = userHasPermission(user, 'admin.threads.messages.delete');
const message = await retryThreadMessageModeration(Number(id), user.id, canRetryAny);
return message ? message : notFound(reply, request);
});
app.put('/api/threads/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
if (!user) {

View File

@@ -9,7 +9,7 @@ import type { ThreadMessage, ThreadReactionCounts, ThreadReactionType, ThreadSum
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.message.moderation'; threadId: number; messageId: number; message: ThreadMessage | null }
| {
type: 'thread.reactions.updated';
target: 'thread' | 'message';
@@ -311,7 +311,7 @@ export async function applyApprovedThreadMessage(messageId: number): Promise<voi
lastActiveAt: row.lastActiveAt,
createdAt: row.threadCreatedAt,
author: row.threadAuthor,
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
reactionCounts: {},
myReactions: [],
followed: true,
unread: true
@@ -326,16 +326,37 @@ export async function applyApprovedThreadMessage(messageId: number): Promise<voi
createdAt: row.messageCreatedAt,
updatedAt: row.messageUpdatedAt,
author: row.messageAuthor,
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
reactionCounts: {},
myReactions: []
}
);
}
export async function publishThreadMessageModeration(threadId: number, message: ThreadMessage | null): Promise<void> {
await publishToUsers([...new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()])], {
export async function publishThreadMessageModeration(
threadId: number,
messageId: number,
message: ThreadMessage | null
): Promise<void> {
const publicUsers = new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()]);
if (message?.author?.id) {
publicUsers.delete(message.author.id);
}
await publishToUsers([...publicUsers], {
type: 'thread.message.moderation',
threadId,
messageId,
message: null
});
if (!message?.author?.id) {
return;
}
await publishToUsers([message.author.id], {
type: 'thread.message.moderation',
threadId,
messageId,
message
});
}