mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Gates the previously open support-agent, procurement, compliance, facilities, list-users and trade-access controllers, separates customer from staff routes across bookings, contracts, companies, billing, warehouses, files and train scheduling, and moves billing, overview, reports and the settings controllers onto their own keys instead of the blanket admin key. Drops the demo-permissions module and the untested notification test route.
213 lines
6.6 KiB
TypeScript
213 lines
6.6 KiB
TypeScript
import {
|
|
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 { 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 };
|
|
}
|
|
|
|
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(),
|
|
};
|
|
}
|
|
}
|