mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
240 lines
7.5 KiB
TypeScript
240 lines
7.5 KiB
TypeScript
import {
|
|
NotificationAudience,
|
|
NotificationChannels,
|
|
NotificationChannelsSent,
|
|
NotificationDto,
|
|
NotificationListResult,
|
|
NotificationPriority,
|
|
NotificationType,
|
|
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 { 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,
|
|
@InjectRepository(User)
|
|
private readonly users: Repository<User>,
|
|
) {}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async notify(input: NotifyInput): Promise<void> {
|
|
try {
|
|
const userIds = await this.recipients.resolve(input.recipients);
|
|
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<NotificationListResult> {
|
|
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 };
|
|
}
|
|
|
|
/** [dev/verification only] Send a canned notification straight to one user. */
|
|
async sendTestToUser(
|
|
userId: string,
|
|
body: {
|
|
audience?: NotificationAudience;
|
|
type?: NotificationType;
|
|
priority?: NotificationPriority;
|
|
title?: string;
|
|
message?: string;
|
|
},
|
|
): Promise<NotificationDto> {
|
|
const entity = await this.repo.create({
|
|
recipientUserId: userId,
|
|
audience: body.audience ?? NotificationAudience.BACKOFFICE,
|
|
type: body.type ?? NotificationType.GENERIC,
|
|
title: body.title ?? "Test notification",
|
|
body: body.message ?? "This is a test in-app notification.",
|
|
priority: body.priority ?? NotificationPriority.NORMAL,
|
|
isRead: false,
|
|
});
|
|
const dto = this.toDto(entity);
|
|
this.gateway.emitNew(userId, dto, await this.repo.countUnread(userId));
|
|
return dto;
|
|
}
|
|
|
|
private async deliverToUser(
|
|
userId: string,
|
|
input: NotifyInput,
|
|
priority: NotificationPriority,
|
|
): Promise<void> {
|
|
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<NotificationChannels> {
|
|
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<NotificationChannels>,
|
|
): Promise<NotificationChannelsSent | null> {
|
|
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(),
|
|
};
|
|
}
|
|
}
|