Files
edr-platform/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts
2026-07-06 10:54:43 +00:00

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(),
};
}
}