import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../infra/prisma.service'; import { NotificationPublisherService } from '../infra/notification-publisher.service'; @Injectable() export class NotificationsService { constructor( private readonly prisma: PrismaService, private readonly publisher: NotificationPublisherService ) {} async create( userId: string, type: string, title: string, message: string, payload?: Record, options?: { skipPublish?: boolean } ) { const notification = await this.prisma.notification.create({ data: { userId, type, title, message, payload: (payload ?? undefined) as never } }); if (!options?.skipPublish) { await this.publisher.publish({ userId, type, title, message, payload: { notificationId: notification.id, ...(payload ?? {}) } }); } return this.toResponse(notification); } async publishRealtime(userId: string, type: string, title: string, message: string, payload?: Record) { await this.publisher.publish({ userId, type, title, message, payload }); } async list(userId: string, limit = 30, unreadOnly = false) { const notifications = await this.prisma.notification.findMany({ where: { userId, ...(unreadOnly ? { isRead: false } : {}) }, orderBy: { createdAt: 'desc' }, take: Math.min(Math.max(limit, 1), 100) }); const inviteIds = notifications .filter((item) => item.type === 'family_invite') .map((item) => (item.payload as { inviteId?: string } | null)?.inviteId) .filter((inviteId): inviteId is string => Boolean(inviteId)); const pendingInviteIds = new Set(); if (inviteIds.length) { const invites = await this.prisma.familyInvite.findMany({ where: { id: { in: inviteIds }, status: 'PENDING' } }); invites.forEach((invite) => pendingInviteIds.add(invite.id)); } const staleIds: string[] = []; const filtered = notifications.filter((item) => { if (item.type !== 'family_invite') return true; const inviteId = (item.payload as { inviteId?: string } | null)?.inviteId; if (!inviteId || !pendingInviteIds.has(inviteId)) { staleIds.push(item.id); return false; } return true; }); if (staleIds.length) { await this.prisma.notification.deleteMany({ where: { id: { in: staleIds }, userId } }); } return { notifications: filtered.map((item) => this.toResponse(item)) }; } async unreadCount(userId: string) { const count = await this.prisma.notification.count({ where: { userId, isRead: false } }); return { count }; } async markRead(userId: string, notificationId: string) { return this.delete(userId, notificationId); } async markAllRead(userId: string) { return this.deleteAll(userId); } async delete(userId: string, notificationId: string) { const existing = await this.prisma.notification.findFirst({ where: { id: notificationId, userId } }); if (!existing) { throw new NotFoundException('Уведомление не найдено'); } await this.prisma.notification.delete({ where: { id: notificationId } }); return { count: 1 }; } async deleteAll(userId: string) { const result = await this.prisma.notification.deleteMany({ where: { userId } }); return { count: result.count }; } async dismissOtpCodeNotifications(userId: string) { const result = await this.prisma.notification.deleteMany({ where: { userId, type: 'otp_code' } }); return { count: result.count }; } async dismissFamilyInvite(userId: string, inviteId: string) { const items = await this.prisma.notification.findMany({ where: { userId, type: 'family_invite' } }); const ids = items .filter((item) => { const payload = item.payload as { inviteId?: string } | null; return payload?.inviteId === inviteId; }) .map((item) => item.id); if (!ids.length) { return { count: 0 }; } await this.prisma.notification.deleteMany({ where: { id: { in: ids }, userId } }); return { count: ids.length }; } private toResponse(notification: { id: string; userId: string; type: string; title: string; message: string; payload: unknown; isRead: boolean; createdAt: Date; }) { return { id: notification.id, userId: notification.userId, type: notification.type, title: notification.title, message: notification.message, payloadJson: notification.payload ? JSON.stringify(notification.payload) : '', isRead: notification.isRead, createdAt: notification.createdAt.toISOString() }; } }