import { Injectable, NotFoundException } from '@nestjs/common'; import { NOTIFICATION_PAGE_SIZE, type NotificationListView, type NotificationPayload, type NotificationType, type NotificationView, } from '@dorfteich/shared'; import { Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; /** * Notification generation and the in-app center's data (issue #94). * Fan-out: everyone watching the page or its pond, minus the actors, and * only where page read permission holds at delivery time (a revoked * watcher gets nothing — permissions.md). The payload is denormalized so * the list renders without joins; it reflects the state at event time. */ @Injectable() export class NotificationsService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionService, private readonly logger: PinoLogger, ) { this.logger.setContext(NotificationsService.name); } /** * Fans a page event out to its watchers. Never throws — a notification * failure must not break the write that caused it. */ async fanoutPageEvent(type: NotificationType, pageId: string, actorIds: string[]): Promise { try { await this.fanout(type, pageId, actorIds); } catch (error) { this.logger.warn({ pageId, type, err: error }, 'notification fan-out failed'); } } private async fanout(type: NotificationType, pageId: string, actorIds: string[]): Promise { const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null }, select: { id: true, pondId: true, title: true, slug: true }, }); if (!page) return; const pond = await this.prisma.pond.findFirst({ where: { id: page.pondId, deletedAt: null }, select: { name: true, slug: true }, }); if (!pond) return; const watches = await this.prisma.watch.findMany({ where: { OR: [ { targetType: 'PAGE', targetId: page.id }, { targetType: 'POND', targetId: page.pondId }, ], }, select: { userId: true }, }); const watcherIds = [...new Set(watches.map((watch) => watch.userId))].filter( (userId) => !actorIds.includes(userId), ); if (watcherIds.length === 0) return; const actors = await this.prisma.user.findMany({ where: { id: { in: actorIds } }, select: { displayName: true }, }); const payload: NotificationPayload = { pageId: page.id, pageTitle: page.title, pageSlug: page.slug, pondSlug: pond.slug, pondName: pond.name, actorNames: actors.slice(0, 3).map((actor) => actor.displayName), }; const watchers = await this.prisma.user.findMany({ where: { id: { in: watcherIds } } }); for (const watcher of watchers) { // Delivery-time permission re-check: only watchers who may still read. if (!(await this.permissions.canAccessPage(watcher, page, 'read'))) continue; await this.prisma.notification.create({ data: { userId: watcher.id, type, payload: payload as unknown as Prisma.InputJsonObject, }, }); } } async list(user: User, page: number): Promise { const where = { userId: user.id }; const [total, unreadCount] = [ await this.prisma.notification.count({ where }), await this.prisma.notification.count({ where: { ...where, readAt: null } }), ]; const pageCount = Math.max(1, Math.ceil(total / NOTIFICATION_PAGE_SIZE)); const current = Math.min(page, pageCount); const rows = await this.prisma.notification.findMany({ where, // Unread first, newest first within each group. orderBy: [{ readAt: { sort: 'asc', nulls: 'first' } }, { createdAt: 'desc' }], skip: (current - 1) * NOTIFICATION_PAGE_SIZE, take: NOTIFICATION_PAGE_SIZE, }); return { notifications: rows.map((row) => this.viewOf(row)), unreadCount, page: current, pageCount, }; } async markRead(user: User, id: string): Promise { const row = await this.prisma.notification.findFirst({ where: { id, userId: user.id } }); if (!row) throw new NotFoundException(); const updated = await this.prisma.notification.update({ where: { id }, data: { readAt: row.readAt ?? new Date() }, }); return this.viewOf(updated); } async markAllRead(user: User): Promise { await this.prisma.notification.updateMany({ where: { userId: user.id, readAt: null }, data: { readAt: new Date() }, }); } private viewOf(row: { id: string; type: string; payload: Prisma.JsonValue; createdAt: Date; readAt: Date | null; }): NotificationView { return { id: row.id, type: row.type as NotificationType, payload: row.payload as unknown as NotificationView['payload'], createdAt: row.createdAt.toISOString(), readAt: row.readAt?.toISOString() ?? null, }; } }