mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
263 lines
7.7 KiB
TypeScript
263 lines
7.7 KiB
TypeScript
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
import { OnEvent } from '@nestjs/event-emitter';
|
|
import { PrismaService } from '../../common/prisma.service';
|
|
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
|
|
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
|
|
|
|
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
|
|
|
@Injectable()
|
|
export class NotificationsService {
|
|
private readonly logger = new Logger(NotificationsService.name);
|
|
private readonly channels: Map<NotificationChannelType, NotificationChannel>;
|
|
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private emailAdapter: EmailAdapter,
|
|
private smsAdapter: SmsAdapter,
|
|
private pushAdapter: PushAdapter,
|
|
) {
|
|
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
|
['EMAIL', this.emailAdapter as NotificationChannel],
|
|
['SMS', this.smsAdapter as NotificationChannel],
|
|
['PUSH', this.pushAdapter as NotificationChannel],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Send notification using template key and context
|
|
* @param templateKey - Template code from NotificationTemplate table
|
|
* @param recipient - User/Passenger ID or email/phone
|
|
* @param context - Variables to interpolate in template
|
|
* @param channels - Optional array of channels to use (defaults to user preferences)
|
|
*/
|
|
async send(
|
|
templateKey: string,
|
|
recipient: string,
|
|
context: Record<string, unknown>,
|
|
channels?: NotificationChannelType[],
|
|
): Promise<{ sent: boolean; channels: string[] }> {
|
|
const template = await this.prisma.notificationTemplate.findUnique({
|
|
where: { code: templateKey },
|
|
});
|
|
|
|
if (!template || !template.active) {
|
|
this.logger.warn(`Template ${templateKey} not found or inactive`);
|
|
return { sent: false, channels: [] };
|
|
}
|
|
|
|
const { subject, body } = this.interpolate(template, context);
|
|
const targetChannels = channels || await this.getUserPreferredChannels(recipient);
|
|
const sentChannels: string[] = [];
|
|
|
|
// Always create in-app notification
|
|
if (targetChannels.includes('IN_APP')) {
|
|
await this.createInAppNotification(recipient, subject, body, context);
|
|
sentChannels.push('IN_APP');
|
|
}
|
|
|
|
// Send via other channels
|
|
for (const channelType of targetChannels) {
|
|
if (channelType === 'IN_APP') continue;
|
|
|
|
const adapter = this.channels.get(channelType);
|
|
if (!adapter) {
|
|
this.logger.warn(`No adapter for channel: ${channelType}`);
|
|
continue;
|
|
}
|
|
|
|
const recipientAddress = await this.getRecipientAddress(recipient, channelType);
|
|
if (!recipientAddress) {
|
|
this.logger.warn(`No ${channelType} address for recipient: ${recipient}`);
|
|
continue;
|
|
}
|
|
|
|
const success = await adapter.send(recipientAddress, subject, body, context);
|
|
if (success) {
|
|
sentChannels.push(channelType);
|
|
}
|
|
}
|
|
|
|
return { sent: sentChannels.length > 0, channels: sentChannels };
|
|
}
|
|
|
|
/**
|
|
* Legacy method for backward compatibility
|
|
*/
|
|
async sendDirect(dto: SendNotificationDto) {
|
|
const notification = await this.prisma.notification.create({
|
|
data: {
|
|
passengerId: dto.passengerId,
|
|
title: dto.title,
|
|
body: dto.body,
|
|
category: dto.category as any,
|
|
deepLink: dto.deepLink,
|
|
metadata: dto.metadata,
|
|
},
|
|
});
|
|
|
|
const passenger = await this.prisma.passenger.findUnique({
|
|
where: { id: dto.passengerId },
|
|
include: { user: true },
|
|
});
|
|
|
|
if (passenger?.user) {
|
|
await this.emailAdapter.send(
|
|
passenger.user.email,
|
|
this.sanitize(dto.title),
|
|
this.sanitize(dto.body),
|
|
);
|
|
}
|
|
|
|
return notification;
|
|
}
|
|
|
|
private async createInAppNotification(
|
|
recipient: string,
|
|
title: string,
|
|
body: string,
|
|
context: Record<string, unknown>,
|
|
): Promise<void> {
|
|
// Try to find passenger by ID or email
|
|
let passengerId = recipient;
|
|
|
|
if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: {
|
|
OR: [{ email: recipient }, { phone: recipient }],
|
|
},
|
|
include: { passenger: true },
|
|
});
|
|
if (user?.passenger) {
|
|
passengerId = user.passenger.id;
|
|
} else {
|
|
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
await this.prisma.notification.create({
|
|
data: {
|
|
passengerId,
|
|
title,
|
|
body,
|
|
category: (context.category as any) || 'SYSTEM',
|
|
deepLink: context.deepLink as string,
|
|
metadata: context as any,
|
|
},
|
|
});
|
|
}
|
|
|
|
private interpolate(
|
|
template: { subject?: string | null; bodyTemplate: string },
|
|
context: Record<string, unknown>,
|
|
): { subject: string; body: string } {
|
|
const subject = template.subject || 'Notification';
|
|
let body = template.bodyTemplate;
|
|
|
|
// Simple template interpolation: {{variable}}
|
|
for (const [key, value] of Object.entries(context)) {
|
|
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
|
|
body = body.replace(regex, String(value));
|
|
}
|
|
|
|
return { subject, body };
|
|
}
|
|
|
|
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: {
|
|
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
|
},
|
|
include: { preferences: true },
|
|
});
|
|
|
|
if (!user?.preferences) {
|
|
return ['IN_APP', 'EMAIL'];
|
|
}
|
|
|
|
const channels: NotificationChannelType[] = ['IN_APP'];
|
|
if (user.preferences.emailEnabled) channels.push('EMAIL');
|
|
if (user.preferences.smsEnabled) channels.push('SMS');
|
|
if (user.preferences.pushEnabled) channels.push('PUSH');
|
|
|
|
return channels;
|
|
}
|
|
|
|
private async getRecipientAddress(
|
|
recipient: string,
|
|
channel: NotificationChannelType,
|
|
): Promise<string | null> {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: {
|
|
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
|
},
|
|
});
|
|
|
|
if (!user) return null;
|
|
|
|
switch (channel) {
|
|
case 'EMAIL':
|
|
return user.email;
|
|
case 'SMS':
|
|
return user.phone;
|
|
case 'PUSH':
|
|
// Would need to fetch device push token
|
|
return user.id;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private sanitize(value: string): string {
|
|
return value
|
|
.replace(/[\r\n]/g, ' ')
|
|
.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
|
|
}
|
|
|
|
getForPassenger(passengerId: string) {
|
|
return this.prisma.notification.findMany({
|
|
where: { passengerId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 50,
|
|
});
|
|
}
|
|
|
|
markRead(id: string) {
|
|
return this.prisma.notification.update({ where: { id }, data: { read: true } });
|
|
}
|
|
|
|
async markAllRead(passengerId: string) {
|
|
await this.prisma.notification.updateMany({
|
|
where: { passengerId, read: false },
|
|
data: { read: true },
|
|
});
|
|
return { updated: true };
|
|
}
|
|
|
|
@OnEvent('booking.created')
|
|
async onBookingCreated(payload: any) {
|
|
await this.send(
|
|
'booking.created',
|
|
payload.booking.passengerId,
|
|
{
|
|
bookingRef: payload.booking.bookingRef,
|
|
category: 'BOOKING',
|
|
deepLink: `edr://bookings/${payload.booking.bookingRef}`,
|
|
},
|
|
);
|
|
}
|
|
|
|
@OnEvent('payment.succeeded')
|
|
async onPaymentSucceeded(payload: any) {
|
|
await this.send(
|
|
'payment.succeeded',
|
|
payload.booking.passengerId,
|
|
{
|
|
bookingRef: payload.booking.bookingRef,
|
|
category: 'PAYMENT',
|
|
deepLink: `edr://tickets/${payload.booking.bookingRef}`,
|
|
},
|
|
);
|
|
}
|
|
} |