From db9114a1df7fc0ffec04d3b019d7f457e38344bd Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:20:23 +0330 Subject: [PATCH] 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. --- app/backend/src/routes/notifications.ts | 190 ++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 app/backend/src/routes/notifications.ts diff --git a/app/backend/src/routes/notifications.ts b/app/backend/src/routes/notifications.ts new file mode 100644 index 0000000..8d94e3a --- /dev/null +++ b/app/backend/src/routes/notifications.ts @@ -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(); + }); +}