import { Injectable, Logger } from '@nestjs/common'; import { brand } from '@mento/shared'; import type { NotificationDto, NotificationsListDto } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { SmsService } from '../common/sms.service'; export type CreateNotificationInput = { organizationId: string; userId: string; type: string; title: string; body?: string | null; link?: string | null; entityType?: string | null; entityId?: string | null; /** If true, skip when an unread notification with same type+entityId exists for user */ dedupe?: boolean; }; @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); constructor( private readonly prisma: PrismaService, private readonly sms: SmsService, ) {} async list( organizationId: string, userId: string, limit = 30, ): Promise { await this.syncDueItems(organizationId, userId); const [items, unreadCount] = await this.prisma.$transaction([ this.prisma.notification.findMany({ where: { organizationId, userId }, orderBy: { createdAt: 'desc' }, take: Math.min(Math.max(limit, 1), 100), }), this.prisma.notification.count({ where: { organizationId, userId, readAt: null }, }), ]); return { items: items.map((row) => this.toDto(row)), unreadCount, }; } async unreadCount(organizationId: string, userId: string): Promise { await this.syncDueItems(organizationId, userId); return this.prisma.notification.count({ where: { organizationId, userId, readAt: null }, }); } async markRead( organizationId: string, userId: string, id: string, ): Promise { const row = await this.prisma.notification.findFirst({ where: { id, organizationId, userId }, }); if (!row) return null; if (row.readAt) return this.toDto(row); const updated = await this.prisma.notification.update({ where: { id: row.id }, data: { readAt: new Date() }, }); return this.toDto(updated); } async markAllRead(organizationId: string, userId: string): Promise { const result = await this.prisma.notification.updateMany({ where: { organizationId, userId, readAt: null }, data: { readAt: new Date() }, }); return result.count; } async create(input: CreateNotificationInput): Promise { if (input.dedupe && input.entityId) { const existing = await this.prisma.notification.findFirst({ where: { userId: input.userId, type: input.type, entityId: input.entityId, }, select: { id: true }, }); if (existing) return false; } await this.prisma.notification.create({ data: { organizationId: input.organizationId, userId: input.userId, type: input.type, title: input.title, body: input.body ?? null, link: input.link ?? null, entityType: input.entityType ?? null, entityId: input.entityId ?? null, }, }); return true; } /** Create overdue/reminder notifications for the current user (idempotent). */ async syncDueItems(organizationId: string, userId: string): Promise { await this.collapseDuplicates(userId); const now = new Date(); const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); const overdue = await this.prisma.activity.findMany({ where: { organizationId, assigneeId: userId, status: 'open', dueAt: { lt: startOfToday }, }, select: { id: true, title: true }, take: 20, }); for (const activity of overdue) { await this.create({ organizationId, userId, type: 'activity_overdue', title: 'فعالیت عقب‌افتاده', body: activity.title, link: `/activities/${activity.id}`, entityType: 'activity', entityId: activity.id, dedupe: true, }); } const dueActivities = await this.prisma.activity.findMany({ where: { organizationId, assigneeId: userId, status: 'open', reminderAt: { lte: now }, }, select: { id: true, title: true, reminderNotifyApp: true, reminderNotifySms: true, assignee: { select: { mobile: true, fullName: true } }, }, take: 20, }); for (const activity of dueActivities) { if (activity.reminderNotifyApp) { await this.create({ organizationId, userId, type: 'activity_reminder', title: 'یادآوری فعالیت', body: activity.title, link: `/activities/${activity.id}`, entityType: 'activity', entityId: activity.id, dedupe: true, }); } if (activity.reminderNotifySms) { await this.sendActivityReminderSms( activity.assignee?.mobile, activity.title, ); } await this.prisma.activity.update({ where: { id: activity.id }, data: { reminderAt: null }, }); } const dueNotes = await this.prisma.note.findMany({ where: { organizationId, authorId: userId, reminderAt: { lte: now }, }, select: { id: true, title: true, body: true }, take: 20, }); for (const note of dueNotes) { await this.create({ organizationId, userId, type: 'note_reminder', title: 'یادآوری یادداشت', body: note.title || note.body.slice(0, 80), link: '/notes', entityType: 'note', entityId: note.id, dedupe: true, }); await this.prisma.note.update({ where: { id: note.id }, data: { reminderAt: null }, }); } } private async sendActivityReminderSms( mobile: string | null | undefined, title: string, ) { if (!mobile) { this.logger.warn('Activity reminder SMS skipped: assignee has no mobile'); return; } if (!this.sms.isConfigured()) { this.logger.warn('Activity reminder SMS skipped: SMS not configured'); return; } const text = `${brand.reminderPrefixFa} ${title}`.slice(0, 160); const result = await this.sms.sendSmsDetailed(mobile, text); if (!result.ok) { this.logger.warn(`Activity reminder SMS failed: ${result.reason ?? 'unknown'}`); } } /** Keep newest notification per type+entity; drop repeated sync spam. */ private async collapseDuplicates(userId: string): Promise { const rows = await this.prisma.notification.findMany({ where: { userId, entityId: { not: null } }, orderBy: { createdAt: 'desc' }, select: { id: true, type: true, entityId: true }, }); const seen = new Set(); const toDelete: string[] = []; for (const row of rows) { const key = `${row.type}:${row.entityId}`; if (seen.has(key)) toDelete.push(row.id); else seen.add(key); } if (toDelete.length) { await this.prisma.notification.deleteMany({ where: { id: { in: toDelete } }, }); } } private toDto(row: { id: string; type: string; title: string; body: string | null; link: string | null; entityType: string | null; entityId: string | null; readAt: Date | null; createdAt: Date; }): NotificationDto { return { id: row.id, type: row.type, title: row.title, body: row.body, link: row.link, entityType: row.entityType, entityId: row.entityId, readAt: row.readAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), }; } }