feat(moderation): add real-time status updates via WebSocket

Broadcast moderation status changes to the author via WebSocket
Update UI in real-time for Life Posts, Comments, and Discussions
Hide retry moderation button while status is reviewing
This commit is contained in:
2026-05-04 10:54:21 +08:00
parent a25f1661b5
commit 3d6188748d
7 changed files with 335 additions and 16 deletions

View File

@@ -82,7 +82,13 @@ export type NotificationsPage = {
type NotificationWsMessage =
| { type: 'notifications.connected'; unreadCount: number }
| { type: 'notifications.created'; notification: NotificationItem; unreadCount: number }
| { type: 'notifications.unread'; unreadCount: number };
| { type: 'notifications.unread'; unreadCount: number }
| {
type: 'moderation.updated';
target: NotificationTarget;
moderationStatus: NotificationModerationStatus;
moderationLanguageCode: string | null;
};
const defaultNotificationLimit = 15;
const maxNotificationLimit = 50;
@@ -267,6 +273,20 @@ async function publishUnreadCount(userId: number): Promise<void> {
});
}
async function publishModerationUpdate(
userId: number,
target: NotificationTarget,
moderationStatus: NotificationModerationStatus,
moderationLanguageCode: string | null
): Promise<void> {
broadcastNotificationMessage(userId, {
type: 'moderation.updated',
target,
moderationStatus,
moderationLanguageCode
});
}
async function publishInsertedNotification(row: { id: number; recipientUserId: number } | null): Promise<void> {
if (row) {
await publishNotification(row.id, row.recipientUserId);
@@ -539,7 +559,12 @@ export async function createModerationResultNotification(
status: NotificationModerationStatus
): Promise<void> {
if (target.type === 'life-post') {
const row = await queryOne<{ id: number; recipientUserId: number }>(
const row = await queryOne<{
id: number;
recipientUserId: number;
moderationLanguageCode: string | null;
lifePostId: number;
}>(
`
INSERT INTO notifications (
recipient_user_id,
@@ -553,16 +578,47 @@ export async function createModerationResultNotification(
WHERE id = $1
AND deleted_at IS NULL
AND created_by_user_id IS NOT NULL
RETURNING id, recipient_user_id AS "recipientUserId"
RETURNING
id,
recipient_user_id AS "recipientUserId",
(
SELECT ai_moderation_language_code
FROM life_posts
WHERE id = $1
) AS "moderationLanguageCode",
life_post_id AS "lifePostId"
`,
[target.id, status]
);
await publishInsertedNotification(row);
if (row) {
await publishModerationUpdate(
row.recipientUserId,
{
type: 'life-post',
id: row.lifePostId,
path: `/life/${row.lifePostId}`,
lifePostId: row.lifePostId,
lifeCommentId: null,
discussionCommentId: null,
entityType: null,
entityId: null
},
status,
row.moderationLanguageCode
);
}
return;
}
if (target.type === 'life-comment') {
const row = await queryOne<{ id: number; recipientUserId: number }>(
const row = await queryOne<{
id: number;
recipientUserId: number;
moderationLanguageCode: string | null;
lifePostId: number;
lifeCommentId: number;
}>(
`
INSERT INTO notifications (
recipient_user_id,
@@ -587,15 +643,48 @@ export async function createModerationResultNotification(
AND lc.deleted_at IS NULL
AND lp.deleted_at IS NULL
AND lc.created_by_user_id IS NOT NULL
RETURNING id, recipient_user_id AS "recipientUserId"
RETURNING
id,
recipient_user_id AS "recipientUserId",
(
SELECT ai_moderation_language_code
FROM life_post_comments
WHERE id = $1
) AS "moderationLanguageCode",
life_post_id AS "lifePostId",
life_comment_id AS "lifeCommentId"
`,
[target.id, status]
);
await publishInsertedNotification(row);
if (row) {
await publishModerationUpdate(
row.recipientUserId,
{
type: 'life-comment',
id: row.lifeCommentId,
path: `/life/${row.lifePostId}`,
lifePostId: row.lifePostId,
lifeCommentId: row.lifeCommentId,
discussionCommentId: null,
entityType: null,
entityId: null
},
status,
row.moderationLanguageCode
);
}
return;
}
const row = await queryOne<{ id: number; recipientUserId: number }>(
const row = await queryOne<{
id: number;
recipientUserId: number;
moderationLanguageCode: string | null;
discussionCommentId: number;
entityType: DiscussionEntityType;
entityId: number;
}>(
`
INSERT INTO notifications (
recipient_user_id,
@@ -620,11 +709,38 @@ export async function createModerationResultNotification(
WHERE id = $1
AND deleted_at IS NULL
AND created_by_user_id IS NOT NULL
RETURNING id, recipient_user_id AS "recipientUserId"
RETURNING
id,
recipient_user_id AS "recipientUserId",
(
SELECT ai_moderation_language_code
FROM entity_discussion_comments
WHERE id = $1
) AS "moderationLanguageCode",
discussion_comment_id AS "discussionCommentId",
entity_type AS "entityType",
entity_id AS "entityId"
`,
[target.id, status]
);
await publishInsertedNotification(row);
if (row) {
await publishModerationUpdate(
row.recipientUserId,
{
type: 'discussion-comment',
id: row.discussionCommentId,
path: discussionEntityPath(row.entityType, row.entityId) ?? '/',
lifePostId: null,
lifeCommentId: null,
discussionCommentId: row.discussionCommentId,
entityType: row.entityType,
entityId: row.entityId
},
status,
row.moderationLanguageCode
);
}
}
function wsFrame(data: Buffer, opcode = 0x1): Buffer {