Import source: app/backend/src/routes/notifications

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:20:23 +03:30
parent d88b32924e
commit db9114a1df
+190
View File
@@ -0,0 +1,190 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { requireAuth } from '../plugins/session.js';
import { publicNotification, setupSse, subscribeRealtime } from '../lib/realtime.js';
const pushSubscribeSchema = z.object({
endpoint: z.string().url(),
auth: z.string().min(1),
p256dh: z.string().min(1),
userAgent: z.string().optional(),
});
const guestSubscribeSchema = z.object({
endpoint: z.string().url(),
auth: z.string().min(1),
p256dh: z.string().min(1),
topics: z.array(z.string().max(50)).min(1).max(10),
userAgent: z.string().optional(),
});
const pushUnsubscribeSchema = z.object({
endpoint: z.string().url(),
});
const preferencesUpdateSchema = z.array(
z.object({
eventType: z.string().min(1),
channel: z.enum(['push', 'email', 'sms']),
enabled: z.boolean(),
}),
).max(50);
const listSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(50).default(20),
});
const ALL_EVENT_TYPES = [
'comment', 'comment_reply', 'new_article',
'review_approved', 'review_revision', 'review_rejected', 'review_submitted', 'system',
];
const ALL_CHANNELS = ['push', 'email', 'sms'];
function badInput(reply: FastifyReply) {
return reply.code(400).send({ error: 'invalid_input' });
}
export default async function notificationRoutes(app: FastifyInstance) {
app.get('/api/v1/auth/notifications', { preHandler: requireAuth() }, async (req, reply) => {
const parsed = listSchema.safeParse(req.query);
if (!parsed.success) return badInput(reply);
const { page, limit } = parsed.data;
const [total, unread, rows] = await Promise.all([
prisma.notification.count({ where: { userId: req.session!.userId } }),
prisma.notification.count({ where: { userId: req.session!.userId, read: false } }),
prisma.notification.findMany({
where: { userId: req.session!.userId },
orderBy: [{ read: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
]);
return { items: rows.map(publicNotification), unread, total, page, limit, pages: Math.ceil(total / limit) };
});
app.post('/api/v1/auth/notifications/read-all', { preHandler: requireAuth() }, async (req) => {
await prisma.notification.updateMany({ where: { userId: req.session!.userId, read: false }, data: { read: true } });
return { ok: true };
});
app.patch('/api/v1/auth/notifications/:id/read', { preHandler: requireAuth() }, async (req, reply) => {
const { id } = req.params as { id: string };
const existing = await prisma.notification.findFirst({ where: { id, userId: req.session!.userId } });
if (!existing) return reply.code(404).send({ error: 'not_found' });
const row = await prisma.notification.update({ where: { id: existing.id }, data: { read: true } });
return { notification: publicNotification(row) };
});
app.delete('/api/v1/auth/notifications/:id', { preHandler: requireAuth() }, async (req, reply) => {
const { id } = req.params as { id: string };
const existing = await prisma.notification.findFirst({ where: { id, userId: req.session!.userId } });
if (!existing) return reply.code(404).send({ error: 'not_found' });
await prisma.notification.delete({ where: { id: existing.id } });
return { ok: true };
});
app.get('/api/v1/auth/notifications/stream', { preHandler: requireAuth() }, async (req, reply) => {
setupSse(reply);
await subscribeRealtime(reply, (event) => event.event === 'notification' && event.userId === req.session!.userId);
return reply;
});
// ── Notification Preferences ────────────────────────────────────────────────
app.get('/api/v1/auth/notifications/preferences', { preHandler: requireAuth() }, async (req) => {
const userId = req.session!.userId;
const rows = await prisma.notificationPreference.findMany({ where: { userId } });
// Build full matrix: for each eventType+channel not in DB, default is enabled=true
const map = new Map(rows.map((r) => [`${r.eventType}:${r.channel}`, r.enabled]));
const preferences = ALL_EVENT_TYPES.flatMap((eventType) =>
ALL_CHANNELS.map((channel) => ({
eventType,
channel,
enabled: map.get(`${eventType}:${channel}`) ?? true,
})),
);
return { preferences };
});
app.put('/api/v1/auth/notifications/preferences', { preHandler: requireAuth() }, async (req, reply) => {
const parsed = preferencesUpdateSchema.safeParse(req.body);
if (!parsed.success) return badInput(reply);
const userId = req.session!.userId;
await Promise.all(
parsed.data.map(({ eventType, channel, enabled }) =>
prisma.notificationPreference.upsert({
where: { userId_eventType_channel: { userId, eventType, channel } },
create: { userId, eventType, channel, enabled },
update: { enabled },
}),
),
);
return { ok: true };
});
// ── Web Push (authenticated) ────────────────────────────────────────────────
app.get('/api/v1/auth/push/vapid-key', { preHandler: requireAuth() }, async (_req, reply) => {
const publicKey = process.env.VAPID_PUBLIC_KEY;
if (!publicKey) return reply.code(503).send({ error: 'push_not_configured' });
return { publicKey };
});
app.post('/api/v1/auth/push/subscribe', { preHandler: requireAuth() }, async (req, reply) => {
const parsed = pushSubscribeSchema.safeParse(req.body);
if (!parsed.success) return badInput(reply);
const { endpoint, auth, p256dh, userAgent } = parsed.data;
const userId = req.session!.userId;
await prisma.pushSubscription.upsert({
where: { endpoint },
create: { userId, endpoint, auth, p256dh, userAgent },
update: { auth, p256dh, userId },
});
return reply.code(204).send();
});
app.delete('/api/v1/auth/push/unsubscribe', { preHandler: requireAuth() }, async (req, reply) => {
const parsed = pushUnsubscribeSchema.safeParse(req.body);
if (!parsed.success) return badInput(reply);
await prisma.pushSubscription.deleteMany({
where: { userId: req.session!.userId, endpoint: parsed.data.endpoint },
});
return reply.code(204).send();
});
// ── Web Push (guest / unauthenticated) ──────────────────────────────────────
app.get('/api/v1/push/vapid-key', async (_req, reply) => {
const publicKey = process.env.VAPID_PUBLIC_KEY;
if (!publicKey) return reply.code(503).send({ error: 'push_not_configured' });
return { publicKey };
});
app.post('/api/v1/push/guest-subscribe', {
config: { rateLimit: { max: 5, timeWindow: '1 minute' } },
}, async (req, reply) => {
const parsed = guestSubscribeSchema.safeParse(req.body);
if (!parsed.success) return badInput(reply);
const { endpoint, auth, p256dh, topics, userAgent } = parsed.data;
// If session exists, wire to user instead
const userId = req.session?.userId ?? null;
await prisma.pushSubscription.upsert({
where: { endpoint },
create: { userId, endpoint, auth, p256dh, topics, userAgent },
update: { auth, p256dh, topics, ...(userId ? { userId } : {}) },
});
return reply.code(204).send();
});
app.delete('/api/v1/push/guest-unsubscribe', async (req, reply) => {
const parsed = pushUnsubscribeSchema.safeParse(req.body);
if (!parsed.success) return badInput(reply);
// Delete guest subscription by endpoint; don't require auth
await prisma.pushSubscription.deleteMany({
where: { endpoint: parsed.data.endpoint, userId: null },
});
return reply.code(204).send();
});
}