Files
pokopiawiki.tootaio.com/backend/src/notifications.ts
xiaomai 8cb8190554 feat(users): implement user following system and following feed
Add follow/unfollow actions and social stats to user profiles
Introduce Following feed scope in Life view
Add notifications for new followers
2026-05-04 15:49:57 +08:00

1004 lines
28 KiB
TypeScript

import type { FastifyBaseLogger } from 'fastify';
import { createHash, randomBytes } from 'node:crypto';
import type { Server } from 'node:http';
import type { Duplex } from 'node:stream';
import { Buffer } from 'node:buffer';
import { pool, query, queryOne } from './db.ts';
import type { AiModerationStatus } from './aiModeration.ts';
type QueryValue = string | string[] | undefined;
type NotificationModerationStatus = Extract<AiModerationStatus, 'approved' | 'rejected' | 'failed'>;
type NotificationType =
| 'life_post_comment'
| 'life_comment_reply'
| 'discussion_comment_reply'
| 'life_post_reaction'
| 'user_follow'
| 'moderation_result';
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'profile-user';
type ModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
type NotificationCursor = {
createdAt: string;
id: number;
};
type NotificationActor = {
id: number;
displayName: string;
};
type NotificationRow = {
id: number;
recipientUserId: number;
actor: NotificationActor | null;
type: NotificationType;
lifePostId: number | null;
profileUserId: number | null;
lifeCommentId: number | null;
parentLifeCommentId: number | null;
discussionCommentId: number | null;
parentDiscussionCommentId: number | null;
entityType: DiscussionEntityType | null;
entityId: number | null;
reactionType: LifeReactionType | null;
moderationStatus: NotificationModerationStatus | null;
moderationReason: string | null;
readAt: Date | null;
createdAt: Date;
createdAtCursor: string;
updatedAt: Date;
};
export type NotificationTarget = {
type: NotificationTargetType;
id: number;
path: string;
lifePostId: number | null;
profileUserId: number | null;
lifeCommentId: number | null;
discussionCommentId: number | null;
entityType: DiscussionEntityType | null;
entityId: number | null;
};
export type NotificationItem = {
id: number;
type: NotificationType;
actor: NotificationActor | null;
target: NotificationTarget;
reactionType: LifeReactionType | null;
moderationStatus: NotificationModerationStatus | null;
moderationReason: string | null;
readAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export type NotificationsPage = {
items: NotificationItem[];
nextCursor: string | null;
hasMore: boolean;
unreadCount: number;
};
type NotificationWsMessage =
| { type: 'notifications.connected'; unreadCount: number }
| { type: 'notifications.created'; notification: NotificationItem; unreadCount: number }
| { type: 'notifications.unread'; unreadCount: number }
| {
type: 'moderation.updated';
target: NotificationTarget;
moderationStatus: NotificationModerationStatus;
moderationLanguageCode: string | null;
moderationReason: string | null;
};
const defaultNotificationLimit = 15;
const maxNotificationLimit = 50;
const websocketTicketMinutes = 2;
const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const notificationClients = new Map<number, Set<Duplex>>();
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function asString(value: QueryValue): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function cleanNotificationLimit(value: QueryValue): number {
const rawLimit = asString(value);
if (!rawLimit) {
return defaultNotificationLimit;
}
const limit = Number(rawLimit);
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxNotificationLimit) : defaultNotificationLimit;
}
function decodeNotificationCursor(value: QueryValue): NotificationCursor | null {
const rawCursor = asString(value);
if (!rawCursor) {
return null;
}
try {
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<NotificationCursor>;
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
const id = Number(cursor.id);
if (!createdAt || Number.isNaN(new Date(createdAt).getTime()) || !Number.isInteger(id) || id <= 0) {
return null;
}
return { createdAt, id };
} catch {
return null;
}
}
function encodeNotificationCursor(row: Pick<NotificationRow, 'createdAtCursor' | 'id'>): string {
return Buffer.from(JSON.stringify({ createdAt: row.createdAtCursor, id: row.id }), 'utf8').toString('base64url');
}
function notificationProjection(): string {
return `
SELECT
n.id,
n.recipient_user_id AS "recipientUserId",
n.type,
n.life_post_id AS "lifePostId",
n.profile_user_id AS "profileUserId",
n.life_comment_id AS "lifeCommentId",
n.parent_life_comment_id AS "parentLifeCommentId",
n.discussion_comment_id AS "discussionCommentId",
n.parent_discussion_comment_id AS "parentDiscussionCommentId",
n.entity_type AS "entityType",
n.entity_id AS "entityId",
n.reaction_type AS "reactionType",
n.moderation_status AS "moderationStatus",
n.moderation_reason AS "moderationReason",
n.read_at AS "readAt",
n.created_at AS "createdAt",
n.created_at::text AS "createdAtCursor",
n.updated_at AS "updatedAt",
CASE
WHEN actor_user.id IS NULL THEN NULL
ELSE json_build_object('id', actor_user.id, 'displayName', actor_user.display_name)
END AS actor
FROM notifications n
LEFT JOIN users actor_user ON actor_user.id = n.actor_user_id
`;
}
function discussionEntityPath(entityType: DiscussionEntityType | null, entityId: number | null): string | null {
if (!entityType || !entityId) {
return null;
}
return `/${entityType}/${entityId}`;
}
function notificationTargetType(row: NotificationRow): NotificationTargetType {
if (row.profileUserId !== null) {
return 'profile-user';
}
if (row.discussionCommentId !== null) {
return 'discussion-comment';
}
if (row.lifeCommentId !== null) {
return 'life-comment';
}
return 'life-post';
}
function notificationPath(row: NotificationRow): string {
if (row.profileUserId !== null) {
return `/profile/${row.profileUserId}`;
}
if (row.lifePostId !== null) {
return `/life/${row.lifePostId}`;
}
return discussionEntityPath(row.entityType, row.entityId) ?? '/';
}
function toNotificationItem(row: NotificationRow): NotificationItem {
const targetType = notificationTargetType(row);
const targetId =
targetType === 'profile-user'
? row.profileUserId
: targetType === 'discussion-comment'
? row.discussionCommentId
: targetType === 'life-comment'
? row.lifeCommentId
: row.lifePostId;
return {
id: row.id,
type: row.type,
actor: row.actor,
target: {
type: targetType,
id: targetId ?? 0,
path: notificationPath(row),
lifePostId: row.lifePostId,
profileUserId: row.profileUserId,
lifeCommentId: row.lifeCommentId,
discussionCommentId: row.discussionCommentId,
entityType: row.entityType,
entityId: row.entityId
},
reactionType: row.reactionType,
moderationStatus: row.moderationStatus,
moderationReason: row.moderationReason,
readAt: row.readAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt
};
}
async function unreadNotificationCount(userId: number): Promise<number> {
const row = await queryOne<{ total: number }>(
`
SELECT COUNT(*)::integer AS total
FROM notifications
WHERE recipient_user_id = $1
AND read_at IS NULL
`,
[userId]
);
return row?.total ?? 0;
}
async function getNotificationById(id: number, userId?: number): Promise<NotificationItem | null> {
const params: unknown[] = [id];
const conditions = ['n.id = $1'];
if (userId !== undefined) {
params.push(userId);
conditions.push(`n.recipient_user_id = $${params.length}`);
}
const row = await queryOne<NotificationRow>(
`
${notificationProjection()}
WHERE ${conditions.join(' AND ')}
`,
params
);
return row ? toNotificationItem(row) : null;
}
async function publishNotification(notificationId: number, userId: number): Promise<void> {
const notification = await getNotificationById(notificationId, userId);
if (!notification) {
return;
}
broadcastNotificationMessage(userId, {
type: 'notifications.created',
notification,
unreadCount: await unreadNotificationCount(userId)
});
}
async function publishUnreadCount(userId: number): Promise<void> {
broadcastNotificationMessage(userId, {
type: 'notifications.unread',
unreadCount: await unreadNotificationCount(userId)
});
}
async function publishModerationUpdate(
userId: number,
target: NotificationTarget,
moderationStatus: NotificationModerationStatus,
moderationLanguageCode: string | null,
moderationReason: string | null
): Promise<void> {
broadcastNotificationMessage(userId, {
type: 'moderation.updated',
target,
moderationStatus,
moderationLanguageCode,
moderationReason
});
}
async function publishInsertedNotification(row: { id: number; recipientUserId: number } | null): Promise<void> {
if (row) {
await publishNotification(row.id, row.recipientUserId);
}
}
export async function listNotifications(userId: number, paramsQuery: Record<string, QueryValue>): Promise<NotificationsPage> {
const limit = cleanNotificationLimit(paramsQuery.limit);
const cursor = decodeNotificationCursor(paramsQuery.cursor);
const params: unknown[] = [userId];
const conditions = ['n.recipient_user_id = $1'];
if (cursor) {
params.push(cursor.createdAt, cursor.id);
conditions.push(`(n.created_at, n.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}
params.push(limit + 1);
const rows = await query<NotificationRow>(
`
${notificationProjection()}
WHERE ${conditions.join(' AND ')}
ORDER BY n.created_at DESC, n.id DESC
LIMIT $${params.length}
`,
params
);
const items = rows.slice(0, limit);
const last = items.at(-1) ?? null;
return {
items: items.map(toNotificationItem),
nextCursor: rows.length > limit && last ? encodeNotificationCursor(last) : null,
hasMore: rows.length > limit,
unreadCount: await unreadNotificationCount(userId)
};
}
export async function markNotificationRead(notificationId: number, userId: number): Promise<{
notification: NotificationItem | null;
unreadCount: number;
}> {
const row = await queryOne<{ id: number }>(
`
UPDATE notifications
SET read_at = COALESCE(read_at, now()),
updated_at = now()
WHERE id = $1
AND recipient_user_id = $2
RETURNING id
`,
[notificationId, userId]
);
const unreadCount = await unreadNotificationCount(userId);
if (row) {
broadcastNotificationMessage(userId, { type: 'notifications.unread', unreadCount });
}
return {
notification: row ? await getNotificationById(row.id, userId) : null,
unreadCount
};
}
export async function markAllNotificationsRead(userId: number): Promise<{ unreadCount: number }> {
await pool.query(
`
UPDATE notifications
SET read_at = COALESCE(read_at, now()),
updated_at = now()
WHERE recipient_user_id = $1
AND read_at IS NULL
`,
[userId]
);
await publishUnreadCount(userId);
return { unreadCount: 0 };
}
export async function createNotificationWebSocketTicket(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
await pool.query(
`
DELETE FROM notification_ws_tickets
WHERE expires_at <= now()
OR used_at IS NOT NULL
`
);
const ticket = randomBytes(32).toString('base64url');
const row = await queryOne<{ expiresAt: Date }>(
`
INSERT INTO notification_ws_tickets (user_id, token_hash, expires_at)
VALUES ($1, $2, now() + ($3 * interval '1 minute'))
RETURNING expires_at AS "expiresAt"
`,
[userId, hashToken(ticket), websocketTicketMinutes]
);
return { ticket, expiresAt: row?.expiresAt ?? new Date(Date.now() + websocketTicketMinutes * 60 * 1000) };
}
async function consumeNotificationWebSocketTicket(ticket: string): Promise<number | null> {
if (ticket.length < 32) {
return null;
}
const row = await queryOne<{ userId: number }>(
`
UPDATE notification_ws_tickets
SET used_at = now()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
RETURNING user_id AS "userId"
`,
[hashToken(ticket)]
);
return row?.userId ?? null;
}
export async function createLifePostReactionNotification(postId: number, actorUserId: number): Promise<void> {
const row = await queryOne<{ id: number; recipientUserId: number }>(
`
INSERT INTO notifications (
recipient_user_id,
actor_user_id,
type,
life_post_id,
reaction_type,
read_at,
created_at,
updated_at
)
SELECT
lp.created_by_user_id,
lpr.user_id,
'life_post_reaction',
lpr.post_id,
lpr.reaction_type,
NULL,
now(),
now()
FROM life_post_reactions lpr
JOIN life_posts lp ON lp.id = lpr.post_id
WHERE lpr.post_id = $1
AND lpr.user_id = $2
AND lp.deleted_at IS NULL
AND lp.created_by_user_id IS NOT NULL
AND lp.created_by_user_id <> lpr.user_id
ON CONFLICT (recipient_user_id, actor_user_id, life_post_id)
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL
DO UPDATE SET reaction_type = EXCLUDED.reaction_type,
read_at = NULL,
created_at = now(),
updated_at = now()
RETURNING id, recipient_user_id AS "recipientUserId"
`,
[postId, actorUserId]
);
await publishInsertedNotification(row);
}
export async function createUserFollowNotification(actorUserId: number, followedUserId: number): Promise<void> {
const row = await queryOne<{ id: number; recipientUserId: number }>(
`
INSERT INTO notifications (
recipient_user_id,
actor_user_id,
type,
profile_user_id,
read_at,
created_at,
updated_at
)
SELECT
followed_user.id,
actor_user.id,
'user_follow',
actor_user.id,
NULL,
now(),
now()
FROM users actor_user
JOIN users followed_user ON followed_user.id = $2
WHERE actor_user.id = $1
AND actor_user.id <> followed_user.id
ON CONFLICT (recipient_user_id, actor_user_id, profile_user_id)
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL
DO UPDATE SET read_at = NULL,
created_at = now(),
updated_at = now()
RETURNING id, recipient_user_id AS "recipientUserId"
`,
[actorUserId, followedUserId]
);
await publishInsertedNotification(row);
}
export async function createApprovedCommentNotification(target: {
type: ModerationTargetType;
id: number;
}): Promise<void> {
if (target.type === 'life-comment') {
const row = await queryOne<{ id: number; recipientUserId: number }>(
`
WITH source AS (
SELECT
lc.id,
lc.post_id,
lc.parent_comment_id,
lc.created_by_user_id AS actor_user_id,
CASE
WHEN lc.parent_comment_id IS NULL THEN lp.created_by_user_id
ELSE parent_comment.created_by_user_id
END AS recipient_user_id
FROM life_post_comments lc
JOIN life_posts lp ON lp.id = lc.post_id
LEFT JOIN life_post_comments parent_comment ON parent_comment.id = lc.parent_comment_id
WHERE lc.id = $1
AND lc.deleted_at IS NULL
AND lc.ai_moderation_status = 'approved'
AND lp.deleted_at IS NULL
)
INSERT INTO notifications (
recipient_user_id,
actor_user_id,
type,
life_post_id,
life_comment_id,
parent_life_comment_id
)
SELECT
recipient_user_id,
actor_user_id,
CASE WHEN parent_comment_id IS NULL THEN 'life_post_comment' ELSE 'life_comment_reply' END,
post_id,
id,
parent_comment_id
FROM source
WHERE recipient_user_id IS NOT NULL
AND actor_user_id IS NOT NULL
AND recipient_user_id <> actor_user_id
ON CONFLICT DO NOTHING
RETURNING id, recipient_user_id AS "recipientUserId"
`,
[target.id]
);
await publishInsertedNotification(row);
return;
}
if (target.type === 'discussion-comment') {
const row = await queryOne<{ id: number; recipientUserId: number }>(
`
WITH source AS (
SELECT
edc.id,
edc.entity_type,
edc.entity_id,
edc.parent_comment_id,
edc.created_by_user_id AS actor_user_id,
parent_comment.created_by_user_id AS recipient_user_id
FROM entity_discussion_comments edc
JOIN entity_discussion_comments parent_comment ON parent_comment.id = edc.parent_comment_id
WHERE edc.id = $1
AND edc.deleted_at IS NULL
AND edc.ai_moderation_status = 'approved'
AND parent_comment.deleted_at IS NULL
)
INSERT INTO notifications (
recipient_user_id,
actor_user_id,
type,
discussion_comment_id,
parent_discussion_comment_id,
entity_type,
entity_id
)
SELECT
recipient_user_id,
actor_user_id,
'discussion_comment_reply',
id,
parent_comment_id,
entity_type,
entity_id
FROM source
WHERE recipient_user_id IS NOT NULL
AND actor_user_id IS NOT NULL
AND recipient_user_id <> actor_user_id
ON CONFLICT DO NOTHING
RETURNING id, recipient_user_id AS "recipientUserId"
`,
[target.id]
);
await publishInsertedNotification(row);
}
}
export async function createModerationResultNotification(
target: { type: ModerationTargetType; id: number },
status: NotificationModerationStatus
): Promise<void> {
if (target.type === 'life-post') {
const row = await queryOne<{
id: number;
recipientUserId: number;
moderationLanguageCode: string | null;
moderationReason: string | null;
lifePostId: number;
}>(
`
INSERT INTO notifications (
recipient_user_id,
actor_user_id,
type,
life_post_id,
moderation_status,
moderation_reason
)
SELECT created_by_user_id, NULL, 'moderation_result', id, $2, ai_moderation_reason
FROM life_posts
WHERE id = $1
AND deleted_at IS NULL
AND created_by_user_id IS NOT NULL
RETURNING
id,
recipient_user_id AS "recipientUserId",
(
SELECT ai_moderation_language_code
FROM life_posts
WHERE id = $1
) AS "moderationLanguageCode",
(
SELECT ai_moderation_reason
FROM life_posts
WHERE id = $1
) AS "moderationReason",
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,
profileUserId: null,
lifeCommentId: null,
discussionCommentId: null,
entityType: null,
entityId: null
},
status,
row.moderationLanguageCode,
row.moderationReason
);
}
return;
}
if (target.type === 'life-comment') {
const row = await queryOne<{
id: number;
recipientUserId: number;
moderationLanguageCode: string | null;
moderationReason: string | null;
lifePostId: number;
lifeCommentId: number;
}>(
`
INSERT INTO notifications (
recipient_user_id,
actor_user_id,
type,
life_post_id,
life_comment_id,
parent_life_comment_id,
moderation_status,
moderation_reason
)
SELECT
lc.created_by_user_id,
NULL,
'moderation_result',
lc.post_id,
lc.id,
lc.parent_comment_id,
$2,
lc.ai_moderation_reason
FROM life_post_comments lc
JOIN life_posts lp ON lp.id = lc.post_id
WHERE lc.id = $1
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",
(
SELECT ai_moderation_language_code
FROM life_post_comments
WHERE id = $1
) AS "moderationLanguageCode",
(
SELECT ai_moderation_reason
FROM life_post_comments
WHERE id = $1
) AS "moderationReason",
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,
profileUserId: null,
lifeCommentId: row.lifeCommentId,
discussionCommentId: null,
entityType: null,
entityId: null
},
status,
row.moderationLanguageCode,
row.moderationReason
);
}
return;
}
const row = await queryOne<{
id: number;
recipientUserId: number;
moderationLanguageCode: string | null;
moderationReason: string | null;
discussionCommentId: number;
entityType: DiscussionEntityType;
entityId: number;
}>(
`
INSERT INTO notifications (
recipient_user_id,
actor_user_id,
type,
discussion_comment_id,
parent_discussion_comment_id,
entity_type,
entity_id,
moderation_status,
moderation_reason
)
SELECT
created_by_user_id,
NULL,
'moderation_result',
id,
parent_comment_id,
entity_type,
entity_id,
$2,
ai_moderation_reason
FROM entity_discussion_comments
WHERE id = $1
AND deleted_at IS NULL
AND created_by_user_id IS NOT NULL
RETURNING
id,
recipient_user_id AS "recipientUserId",
(
SELECT ai_moderation_language_code
FROM entity_discussion_comments
WHERE id = $1
) AS "moderationLanguageCode",
(
SELECT ai_moderation_reason
FROM entity_discussion_comments
WHERE id = $1
) AS "moderationReason",
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,
profileUserId: null,
lifeCommentId: null,
discussionCommentId: row.discussionCommentId,
entityType: row.entityType,
entityId: row.entityId
},
status,
row.moderationLanguageCode,
row.moderationReason
);
}
}
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: NotificationWsMessage): void {
if (socket.destroyed) {
return;
}
socket.write(wsFrame(Buffer.from(JSON.stringify(message), 'utf8')));
}
function broadcastNotificationMessage(userId: number, message: NotificationWsMessage): void {
const sockets = notificationClients.get(userId);
if (!sockets) {
return;
}
for (const socket of sockets) {
try {
sendWsJson(socket, message);
} catch {
socket.destroy();
sockets.delete(socket);
}
}
}
function addNotificationClient(userId: number, socket: Duplex): void {
const sockets = notificationClients.get(userId) ?? new Set<Duplex>();
sockets.add(socket);
notificationClients.set(userId, sockets);
socket.once('close', () => {
sockets.delete(socket);
if (sockets.size === 0) {
notificationClients.delete(userId);
}
});
}
function websocketPayload(buffer: Buffer): { opcode: number; payload: Buffer } | null {
if (buffer.length < 2) {
return null;
}
const opcode = buffer[0] & 0x0f;
let payloadLength = buffer[1] & 0x7f;
let offset = 2;
if (payloadLength === 126) {
if (buffer.length < offset + 2) return null;
payloadLength = buffer.readUInt16BE(offset);
offset += 2;
} else if (payloadLength === 127) {
if (buffer.length < offset + 8) return null;
const largeLength = buffer.readBigUInt64BE(offset);
if (largeLength > BigInt(Number.MAX_SAFE_INTEGER)) return null;
payloadLength = Number(largeLength);
offset += 8;
}
const masked = (buffer[1] & 0x80) !== 0;
const mask = masked ? buffer.subarray(offset, offset + 4) : null;
if (mask) {
offset += 4;
}
if (buffer.length < offset + payloadLength) {
return null;
}
const payload = Buffer.from(buffer.subarray(offset, offset + payloadLength));
if (mask) {
for (let index = 0; index < payload.length; 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();
}
export function setupNotificationWebSocketServer(server: Server, logger: FastifyBaseLogger): void {
server.on('upgrade', async (request, socket) => {
const url = new URL(request.url ?? '/', 'http://localhost');
if (url.pathname !== '/api/notifications/ws') {
socket.destroy();
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 consumeNotificationWebSocketTicket(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')
);
addNotificationClient(userId, socket);
sendWsJson(socket, {
type: 'notifications.connected',
unreadCount: await unreadNotificationCount(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 }, 'Notification WebSocket upgrade failed');
rejectUpgrade(socket, 500, 'Internal Server Error');
}
});
}