Import source: app/backend/src/lib/realtime
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,171 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import type { NotificationType, Prisma } from '@prisma/client';
|
||||||
|
import { redis } from '../redis.js';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { sendPushToUser } from './push.js';
|
||||||
|
|
||||||
|
const REALTIME_CHANNEL = 'teknav:realtime';
|
||||||
|
const ACTIVE_GUESTS_KEY = 'visitors:guests';
|
||||||
|
const ACTIVE_USERS_KEY = 'visitors:users';
|
||||||
|
const COMMENTS_STATS_KEY = 'stats:comments';
|
||||||
|
const ACTIVE_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export type RealtimeEvent =
|
||||||
|
| { event: 'notification'; userId: string; data: unknown }
|
||||||
|
| { event: 'activity'; data: unknown }
|
||||||
|
| { event: 'visitor_update'; data: { count: number; guests: number; users: number; commentsPerMin: number } }
|
||||||
|
| { event: 'view_update'; data: unknown };
|
||||||
|
|
||||||
|
export async function publishRealtime(event: RealtimeEvent) {
|
||||||
|
await redis.publish(REALTIME_CHANNEL, JSON.stringify(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNotification(userId: string, type: NotificationType, payload: Prisma.InputJsonValue) {
|
||||||
|
const row = await prisma.notification.create({ data: { userId, type, payload } });
|
||||||
|
const data = publicNotification(row);
|
||||||
|
await publishRealtime({ event: 'notification', userId, data });
|
||||||
|
sendPushToUser(userId, type, payload as Record<string, unknown>).catch(() => {});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicNotification(row: {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
payload: Prisma.JsonValue;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
payload: row.payload,
|
||||||
|
read: row.read,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupSse(reply: FastifyReply) {
|
||||||
|
reply.hijack();
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
reply.raw.write('retry: 5000\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSse(reply: FastifyReply, event: string, data: unknown) {
|
||||||
|
reply.raw.write(`event: ${event}\n`);
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeRealtime(
|
||||||
|
reply: FastifyReply,
|
||||||
|
filter: (event: RealtimeEvent) => boolean,
|
||||||
|
) {
|
||||||
|
const subscriber = redis.duplicate();
|
||||||
|
await subscriber.connect();
|
||||||
|
await subscriber.subscribe(REALTIME_CHANNEL);
|
||||||
|
const onMessage = (_channel: string, message: string) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(message) as RealtimeEvent;
|
||||||
|
if (filter(event)) writeSse(reply, event.event, event.data);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed pub/sub payloads.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
subscriber.on('message', onMessage);
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
reply.raw.write(': heartbeat\n\n');
|
||||||
|
}, 25_000);
|
||||||
|
reply.raw.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
subscriber.off('message', onMessage);
|
||||||
|
subscriber.quit().catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordActiveVisitor(req: FastifyRequest) {
|
||||||
|
if (!req.url.startsWith('/api/') || req.url.includes('/stream')) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (req.session?.userId) {
|
||||||
|
await redis.zadd(ACTIVE_USERS_KEY, now, req.session.userId.toString());
|
||||||
|
await redis.pexpire(ACTIVE_USERS_KEY, ACTIVE_WINDOW_MS * 2);
|
||||||
|
} else {
|
||||||
|
const ip = req.ip || 'unknown';
|
||||||
|
await redis.zadd(ACTIVE_GUESTS_KEY, now, ip);
|
||||||
|
await redis.pexpire(ACTIVE_GUESTS_KEY, ACTIVE_WINDOW_MS * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishVisitorCount() {
|
||||||
|
const now = Date.now();
|
||||||
|
const cutoff = now - ACTIVE_WINDOW_MS;
|
||||||
|
const commentCutoff = now - 60 * 1000;
|
||||||
|
|
||||||
|
await redis.zremrangebyscore(ACTIVE_GUESTS_KEY, 0, cutoff);
|
||||||
|
await redis.zremrangebyscore(ACTIVE_USERS_KEY, 0, cutoff);
|
||||||
|
await redis.zremrangebyscore(COMMENTS_STATS_KEY, 0, commentCutoff);
|
||||||
|
|
||||||
|
const guests = await redis.zcard(ACTIVE_GUESTS_KEY);
|
||||||
|
const users = await redis.zcard(ACTIVE_USERS_KEY);
|
||||||
|
const commentsPerMin = await redis.zcard(COMMENTS_STATS_KEY);
|
||||||
|
const count = guests + users;
|
||||||
|
|
||||||
|
await publishRealtime({ event: 'visitor_update', data: { count, guests, users, commentsPerMin } });
|
||||||
|
return { count, guests, users, commentsPerMin };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordCommentEvent() {
|
||||||
|
const now = Date.now();
|
||||||
|
await redis.zadd(COMMENTS_STATS_KEY, now, `${now}-${Math.random()}`);
|
||||||
|
await redis.pexpire(COMMENTS_STATS_KEY, 60 * 1000 * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyFollowersOnPublish(article: {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
author: { userId: string | null; name: string };
|
||||||
|
category?: { slug: string; name?: string } | null;
|
||||||
|
}) {
|
||||||
|
const [writerFollows, topicFollows] = await Promise.all([
|
||||||
|
article.author.userId
|
||||||
|
? prisma.writerFollow.findMany({
|
||||||
|
where: { writerId: article.author.userId },
|
||||||
|
select: { followerId: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
article.category?.slug
|
||||||
|
? prisma.topicFollow.findMany({
|
||||||
|
where: { topic: article.category.slug },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const userIds = new Set([
|
||||||
|
...writerFollows.map((follow) => follow.followerId),
|
||||||
|
...topicFollows.map((follow) => follow.userId),
|
||||||
|
]);
|
||||||
|
await Promise.all([...userIds].map((userId) => createNotification(userId, 'new_article', {
|
||||||
|
articleId: article.id,
|
||||||
|
articleSlug: article.slug,
|
||||||
|
articleTitle: article.title,
|
||||||
|
actorName: article.author.name,
|
||||||
|
topic: article.category?.slug ?? null,
|
||||||
|
topicName: article.category?.name ?? null,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyEditorsOnSubmit(article: { slug: string, title: string, authorName: string }) {
|
||||||
|
const editors = await prisma.user.findMany({
|
||||||
|
where: { role: { in: ['admin', 'editor', 'reviewer'] } },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
await Promise.all(editors.map(e => createNotification(e.id.toString(), 'review_submitted', {
|
||||||
|
articleSlug: article.slug,
|
||||||
|
articleTitle: article.title,
|
||||||
|
actorName: article.authorName,
|
||||||
|
})));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user