import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { DbClient } from '../prisma/db-client'; import { PermissionKey } from '../auth/permissions/permission-catalog'; import { Notification, NotificationCategory, NotificationPriority, Prisma, } from '../../generated/prisma'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { NotificationDeliveryPlanner } from './delivery-planner'; import { assertAppLink, notificationDedupeKey } from './notification-link'; export interface CreateNotificationInput { userId: string; priority: NotificationPriority; category: NotificationCategory; message: string; relatedType?: string | null; relatedId?: string | null; link?: string | null; requiredPermissions?: readonly PermissionKey[]; dedupeKey?: string | null; } export type FanOutNotificationInput = Omit< CreateNotificationInput, 'userId' | 'dedupeKey' > & { dedupeKeyPrefix?: string }; export interface NotificationView { id: string; priority: NotificationPriority; category: NotificationCategory; message: string; relatedType: string | null; relatedId: string | null; link: string | null; read: boolean; createdAt: Date; } type NotificationContent = Omit; interface Target { userId: string; dedupeKey: string | null; } const VIEW_SELECT = { id: true, priority: true, category: true, message: true, relatedType: true, relatedId: true, link: true, read: true, createdAt: true, } satisfies Prisma.NotificationSelect; function toView(row: NotificationView): NotificationView { return { id: row.id, priority: row.priority, category: row.category, message: row.message, relatedType: row.relatedType, relatedId: row.relatedId, link: row.link, read: row.read, createdAt: row.createdAt, }; } const MAX_LISTED = 100; /** Rows read before the permission filter, so hidden rows do not empty the list. */ const MAX_SCANNED = 500; function uniqueSorted(keys: readonly PermissionKey[]): PermissionKey[] { return [...new Set(keys)].sort(); } /** * Every notification belongs to exactly one user. Rows are written only to * active users with an active role who hold every permission the content * needs (the rule the PermissionsGuard applies). The stored * requiredPermissions are the recipient's and the content's permissions * together, so a later consumer (e.g. the Telegram sender) can check them * again. Right after the insert, in the same transaction, the delivery planner * queues the Telegram delivery for users who asked for it. */ @Injectable() export class NotificationsService { constructor( private readonly prisma: PrismaService, private readonly permissionResolver: PermissionResolverService, private readonly deliveryPlanner: NotificationDeliveryPlanner, ) {} /** One recipient. null: repeated dedupe key, or the recipient lacks a right. */ async record( input: CreateNotificationInput, tx?: Prisma.TransactionClient, ): Promise { const db: DbClient = tx ?? this.prisma; const { userId, dedupeKey, ...content } = input; const keys = uniqueSorted(content.requiredPermissions ?? []); const recipients = await this.recipients(db, keys, [userId]); const [row] = await this.write( db, recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })), content, keys, ); return row ?? null; } /** The given users, minus those missing a permission the content needs. */ async recordForUsers( userIds: readonly string[], input: FanOutNotificationInput, tx?: Prisma.TransactionClient, ): Promise { if (userIds.length === 0) { return []; } const keys = uniqueSorted(input.requiredPermissions ?? []); return this.fanOut(tx ?? this.prisma, keys, input, userIds); } /** Every user holding all of `permission` (and the content's permissions). */ async recordForPermission( permission: PermissionKey | readonly PermissionKey[], input: FanOutNotificationInput, tx?: Prisma.TransactionClient, ): Promise { const recipientKeys = typeof permission === 'string' ? [permission] : permission; if (recipientKeys.length === 0) { // An empty list would match every user: a silent broadcast. throw new Error('recordForPermission needs at least one permission'); } const keys = uniqueSorted([ ...recipientKeys, ...(input.requiredPermissions ?? []), ]); return this.fanOut(tx ?? this.prisma, keys, input, undefined); } /** * The own notifications, newest first, at most 100. A row whose * requiredPermissions the user no longer holds (role changed after the * write) stays hidden. */ async findOwn( userId: string, options: { unreadOnly?: boolean } = {}, ): Promise { const rows = await this.prisma.notification.findMany({ where: { userId, ...(options.unreadOnly ? { read: false } : {}) }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: MAX_SCANNED, select: { ...VIEW_SELECT, requiredPermissions: true }, }); const held = await this.permissionResolver.getPermissions(userId); return rows .filter((row) => row.requiredPermissions.every((key) => held.has(key))) .slice(0, MAX_LISTED) .map(toView); } /** * A foreign, unknown or no longer visible row (same rule as findOwn) * answers 404 and is left untouched. */ async markRead(id: string, userId: string): Promise { const row = await this.prisma.notification.findFirst({ where: { id, userId }, select: { requiredPermissions: true }, }); const held = await this.permissionResolver.getPermissions(userId); if (!row || !row.requiredPermissions.every((key) => held.has(key))) { throw new NotFoundException('Notification not found'); } const { count } = await this.prisma.notification.updateMany({ where: { id, userId }, data: { read: true }, }); if (count === 0) { throw new NotFoundException('Notification not found'); } return this.prisma.notification.findFirstOrThrow({ where: { id, userId }, select: VIEW_SELECT, }); } /** Marks only the unread rows the user may see. */ async markAllRead(userId: string): Promise<{ updated: number }> { const unread = await this.prisma.notification.findMany({ where: { userId, read: false }, select: { id: true, requiredPermissions: true }, }); const held = await this.permissionResolver.getPermissions(userId); const visible = unread .filter((row) => row.requiredPermissions.every((key) => held.has(key))) .map((row) => row.id); if (visible.length === 0) { return { updated: 0 }; } const { count } = await this.prisma.notification.updateMany({ where: { id: { in: visible }, userId, read: false }, data: { read: true }, }); return { updated: count }; } private async fanOut( db: DbClient, keys: readonly PermissionKey[], input: FanOutNotificationInput, userIds: readonly string[] | undefined, ): Promise { const { dedupeKeyPrefix, ...content } = input; const recipients = await this.recipients(db, keys, userIds); return this.write( db, recipients.map((userId) => ({ userId, dedupeKey: dedupeKeyPrefix ? notificationDedupeKey(dedupeKeyPrefix, userId) : null, })), content, keys, ); } /** Active user, active role, every key granted - in one query. */ private async recipients( db: DbClient, keys: readonly PermissionKey[], userIds: readonly string[] | undefined, ): Promise { const users = await db.user.findMany({ where: { ...(userIds ? { id: { in: [...userIds] } } : {}), isActive: true, role: { status: 'ACTIVE', AND: keys.map((key) => ({ permissions: { some: { permission: { key } } }, })), }, }, select: { id: true }, orderBy: { id: 'asc' }, }); return users.map((user) => user.id); } private async write( db: DbClient, targets: readonly Target[], content: NotificationContent, recipientKeys: readonly PermissionKey[], ): Promise { if (content.link) { assertAppLink(content.link); } if (targets.length === 0) { return []; } const requiredPermissions = uniqueSorted([ ...recipientKeys, ...(content.requiredPermissions ?? []), ]); const insertAndPlan = async (client: DbClient): Promise => { const rows = await client.notification.createManyAndReturn({ data: targets.map(({ userId, dedupeKey }) => ({ userId, priority: content.priority, category: content.category, message: content.message, relatedType: content.relatedType ?? null, relatedId: content.relatedId ?? null, link: content.link ?? null, requiredPermissions, dedupeKey, })), skipDuplicates: true, }); // Only the rows that were inserted: a repeated dedupe key plans nothing. await this.deliveryPlanner.planIn(client, rows); return rows; }; // Rows and deliveries belong together: without the caller's transaction // they get their own. A caller WITH a transaction gets no nested one: the // insert and the planning run on its client and commit or roll back with it. return '$connect' in db ? db.$transaction((tx) => insertAndPlan(tx)) : insertAndPlan(db); } }