changemaker.lite/api/src/modules/media/routes/chat-notifications.routes.ts

118 lines
3.0 KiB
TypeScript

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import jwt from 'jsonwebtoken';
import { env } from '../../../config/env';
import { UserRole } from '@prisma/client';
/**
* Chat Notifications SSE Routes
*
* Provides per-user SSE streams for real-time chat reply notifications.
* Since EventSource can't send Authorization headers, JWT is passed as query param.
*/
interface TokenPayload {
id: string;
email: string;
role: UserRole;
}
// In-memory subscriber map: userId → Set of SSE writers
const subscribers = new Map<string, Set<(data: string) => void>>();
/**
* Notify a user of a chat reply via SSE
*/
export function notifyUser(userId: string, notification: {
type: 'chat_reply';
videoId: number;
videoTitle: string;
commentId: number;
commenterName: string;
contentPreview: string;
}): void {
const writers = subscribers.get(userId);
if (!writers || writers.size === 0) return;
const data = JSON.stringify(notification);
for (const write of writers) {
try {
write(data);
} catch {
// Writer disconnected, will be cleaned up
}
}
}
export async function chatNotificationsRoutes(fastify: FastifyInstance) {
/**
* GET /notifications/stream?token=JWT
* Per-user SSE stream for chat reply notifications
*/
fastify.get(
'/notifications/stream',
async (
request: FastifyRequest<{ Querystring: { token?: string } }>,
reply: FastifyReply
) => {
const token = request.query.token;
if (!token) {
return reply.code(401).send({ message: 'Authentication token required' });
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
} catch {
return reply.code(401).send({ message: 'Invalid or expired token' });
}
const userId = payload.id;
// Set SSE headers
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Writer function for this connection
const writer = (data: string) => {
reply.raw.write(`data: ${data}\n\n`);
};
// Register subscriber
if (!subscribers.has(userId)) {
subscribers.set(userId, new Set());
}
subscribers.get(userId)!.add(writer);
// Send connection confirmation
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`);
// Keep-alive ping every 30 seconds
const pingInterval = setInterval(() => {
try {
reply.raw.write(': ping\n\n');
} catch {
// Connection closed
}
}, 30000);
// Cleanup on disconnect
request.raw.on('close', () => {
clearInterval(pingInterval);
const writers = subscribers.get(userId);
if (writers) {
writers.delete(writer);
if (writers.size === 0) {
subscribers.delete(userId);
}
}
});
}
);
}