import { NotificationAudience, NotificationChannels, NotificationChannelsSent, NotificationDto, NotificationListResult, NotificationPriority, NotifyInput, } from "@edr/types"; import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; import { ChatBridgeService } from "../chat/chat-bridge.service"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; import { Notification } from "./entities/notification.entity"; import { NotificationInboxRepository } from "./notification-inbox.repository"; import { NotificationRecipientsService } from "./notification-recipients.service"; import { NotificationsGateway } from "./notifications.gateway"; /** * The single entry point subsystems use for in-app notifications. Call * {@link notify}; everything else (reads, mark-read) backs the REST controller. * * `notify` is deliberately fault-tolerant: it never throws into the caller so a * notification failure can't roll back or break the business transaction that * triggered it. Failures are logged. */ @Injectable() export class NotificationInboxService { private readonly logger = new Logger(NotificationInboxService.name); constructor( private readonly repo: NotificationInboxRepository, private readonly recipients: NotificationRecipientsService, private readonly gateway: NotificationsGateway, private readonly emailClient: EmailClientService, private readonly smsClient: SmsClientService, private readonly chatBridge: ChatBridgeService, @InjectRepository(User) private readonly users: Repository, ) {} /** * Fan a logical notification out to every resolved recipient: persist one row * each, push it live over WebSocket, and (for HIGH priority) also queue * email/SMS via the existing clients. BACKOFFICE-audience notifications are * also mirrored into internal chat (ChatBridgeService) — a shared-room * broadcast, not per-recipient, so it runs once regardless of how many (if * any) in-app rows get created below. Never PORTAL — that's customer-facing * and must never reach a staff room. */ async notify(input: NotifyInput): Promise { try { const userIds = await this.recipients.resolve(input.recipients); if (input.audience === NotificationAudience.BACKOFFICE) { await this.chatBridge.bridge(input); } if (userIds.length === 0) { this.logger.debug( `notify(${input.type}) resolved 0 recipients — skipped`, ); return; } const priority = input.priority ?? NotificationPriority.NORMAL; for (const userId of userIds) { await this.deliverToUser(userId, input, priority); } } catch (err) { this.logger.error( `notify failed: ${(err as Error).message}`, (err as Error).stack, ); } } async list( userId: string, query: ListNotificationsQueryDto, ): Promise { const [items, count] = await this.repo.findForRecipient(userId, { page: query.page, limit: query.limit, isRead: query.isRead, }); const unreadCount = await this.repo.countUnread(userId); return { items: items.map((n) => this.toDto(n)), count, unreadCount }; } async unreadCount(userId: string): Promise<{ unreadCount: number }> { return { unreadCount: await this.repo.countUnread(userId) }; } async markRead( id: string, userId: string, ): Promise<{ success: boolean; unreadCount: number }> { const success = await this.repo.markRead(id, userId); const unreadCount = await this.repo.countUnread(userId); this.gateway.emitUnreadCount(userId, unreadCount); return { success, unreadCount }; } async markAllRead( userId: string, ): Promise<{ updated: number; unreadCount: number }> { const updated = await this.repo.markAllRead(userId); const unreadCount = await this.repo.countUnread(userId); this.gateway.emitUnreadCount(userId, unreadCount); return { updated, unreadCount }; } private async deliverToUser( userId: string, input: NotifyInput, priority: NotificationPriority, ): Promise { const entity = await this.repo.create({ recipientUserId: userId, audience: input.audience, type: input.type, title: input.title, body: input.body, link: input.link ?? null, data: input.data ?? null, priority, isRead: false, }); const unreadCount = await this.repo.countUnread(userId); this.gateway.emitNew(userId, this.toDto(entity), unreadCount); const channels = this.resolveChannels(input, priority); if (channels.email || channels.sms) { const channelsSent = await this.fanOut(userId, input, channels); if (channelsSent) { await this.repo.update(entity.id, { channelsSent }); } } } /** * Decide which outbound channels to use. An explicit `input.channels` * selection wins; otherwise fall back to priority (HIGH ⇒ email + SMS). */ private resolveChannels( input: NotifyInput, priority: NotificationPriority, ): Required { if (input.channels) { return { email: input.channels.email === true, sms: input.channels.sms === true, }; } const high = priority === NotificationPriority.HIGH; return { email: high, sms: high }; } /** * Best-effort email/SMS fan-out for the requested channels. Skips a channel * the recipient has no address for. Never throws. */ private async fanOut( userId: string, input: NotifyInput, channels: Required, ): Promise { try { const user = await this.users.findOne({ where: { id: userId } as never, }); if (!user) return null; const sent: NotificationChannelsSent = {}; const text = `${input.title}\n\n${input.body}`; if (channels.email && user.email) { const res = await this.emailClient.sendEmail({ to: user.email, subject: input.title, text, }); sent.email = res.queued; } if (channels.sms && user.phoneNumber) { const res = await this.smsClient.sendSms({ to: user.phoneNumber, message: text, }); sent.sms = res.queued; } return Object.keys(sent).length ? sent : null; } catch (err) { this.logger.warn( `fan-out failed for user ${userId}: ${(err as Error).message}`, ); return null; } } private toDto(n: Notification): NotificationDto { return { id: n.id, recipientUserId: n.recipientUserId, audience: n.audience, type: n.type, title: n.title, body: n.body, link: n.link ?? null, data: n.data ?? null, priority: n.priority, isRead: n.isRead, readAt: n.readAt ? new Date(n.readAt).toISOString() : null, createdAt: new Date(n.createdAt).toISOString(), }; } }