feat: ( notification ) add notification management

This commit is contained in:
Abubeker Yasin
2026-07-08 15:40:31 +03:00
parent 8b957e956e
commit 0c3050b6fe
4 changed files with 350 additions and 48 deletions

View File

@@ -4,7 +4,7 @@ import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { TestNotificationDto } from './notifications.dto';
import { TestNotificationDto, CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
@@ -21,6 +21,39 @@ export class NotificationsController {
private smsClient: SmsClientService,
) {}
// --- Template management (declared before the ':passengerId' catch-all so the
// static 'templates' segment isn't captured as a passenger id) ---
@Get('templates')
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'List notification templates' })
listTemplates() {
return this.service.listTemplates();
}
@Get('templates/:id')
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Get a notification template' })
getTemplate(@Param('id') id: string) {
return this.service.getTemplate(id);
}
@Post('templates')
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a notification template' })
@ApiBody({ type: CreateTemplateDto })
createTemplate(@Body() dto: CreateTemplateDto) {
return this.service.createTemplate(dto);
}
@Patch('templates/:id')
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a notification template (code is immutable)' })
@ApiBody({ type: UpdateTemplateDto })
updateTemplate(@Param('id') id: string, @Body() dto: UpdateTemplateDto) {
return this.service.updateTemplate(id, dto);
}
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
getForPassenger(@Param('passengerId') id: string) {

View File

@@ -1,6 +1,10 @@
import { IsString, IsEnum, IsOptional, IsArray } from 'class-validator';
import { IsString, IsEnum, IsOptional, IsArray, IsBoolean, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/** A single channel, or a comma-separated list of them (e.g. "SMS,EMAIL"). */
const CHANNEL_LIST_RE = /^(EMAIL|SMS|PUSH|IN_APP)(,(EMAIL|SMS|PUSH|IN_APP))*$/;
const CHANNEL_MSG = 'channel must be a comma-separated list of EMAIL, SMS, PUSH, IN_APP';
export enum NotificationCategoryEnum {
BOOKING = 'BOOKING',
PAYMENT = 'PAYMENT',
@@ -18,6 +22,55 @@ export class SendNotificationDto {
@ApiPropertyOptional() @IsOptional() metadata?: Record<string, any>;
}
export class CreateTemplateDto {
@ApiProperty({ example: 'booking.created', description: 'Unique template code / event key' })
@IsString()
code: string;
@ApiProperty({ example: 'SMS,EMAIL', description: 'Comma-separated channels: EMAIL, SMS, PUSH, IN_APP' })
@IsString()
@Matches(CHANNEL_LIST_RE, { message: CHANNEL_MSG })
channel: string;
@ApiPropertyOptional({ example: 'Your train ticket is booked' })
@IsOptional()
@IsString()
subject?: string;
@ApiProperty({ example: 'Dear {{passengerName}}, your booking {{bookingRef}} is booked.' })
@IsString()
bodyTemplate: string;
@ApiPropertyOptional({ example: true, description: 'Defaults to true' })
@IsOptional()
@IsBoolean()
active?: boolean;
}
// `code` is intentionally omitted — it is the immutable event key and cannot be changed.
export class UpdateTemplateDto {
@ApiPropertyOptional({ example: 'SMS,EMAIL' })
@IsOptional()
@IsString()
@Matches(CHANNEL_LIST_RE, { message: CHANNEL_MSG })
channel?: string;
@ApiPropertyOptional({ example: 'Your train ticket is booked' })
@IsOptional()
@IsString()
subject?: string;
@ApiPropertyOptional({ example: 'Dear {{passengerName}}, ...' })
@IsOptional()
@IsString()
bodyTemplate?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
active?: boolean;
}
export class TestNotificationDto {
@ApiProperty({ example: 'booking.created' })
@IsString()

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@@ -6,6 +6,7 @@ import { PrismaService } from '../../common/prisma.service';
import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
@@ -245,22 +246,151 @@ export class NotificationsService {
return { updated: true };
}
// ---------------------------------------------------------------------------
// Template management (backoffice). Templates are keyed by `code`; event
// handlers look them up by that code (e.g. 'booking.created'), so `code` is
// immutable once created — only channel/subject/body/active are editable.
// ---------------------------------------------------------------------------
listTemplates() {
return this.prisma.notificationTemplate.findMany({ orderBy: { code: 'asc' } });
}
async getTemplate(id: string) {
const template = await this.prisma.notificationTemplate.findUnique({ where: { id } });
if (!template) throw new NotFoundException(`Notification template ${id} not found`);
return template;
}
async createTemplate(dto: CreateTemplateDto) {
const existing = await this.prisma.notificationTemplate.findUnique({ where: { code: dto.code } });
if (existing) throw new ConflictException(`Template with code "${dto.code}" already exists`);
return this.prisma.notificationTemplate.create({
data: {
code: dto.code,
channel: dto.channel,
subject: dto.subject ?? null,
bodyTemplate: dto.bodyTemplate,
active: dto.active ?? true,
},
});
}
async updateTemplate(id: string, dto: UpdateTemplateDto) {
await this.getTemplate(id); // 404 if missing
return this.prisma.notificationTemplate.update({
where: { id },
data: {
...(dto.channel !== undefined ? { channel: dto.channel } : {}),
...(dto.subject !== undefined ? { subject: dto.subject } : {}),
...(dto.bodyTemplate !== undefined ? { bodyTemplate: dto.bodyTemplate } : {}),
...(dto.active !== undefined ? { active: dto.active } : {}),
},
});
}
/**
* Booking created (awaiting payment) → the rich "your ticket is booked, here is the pay link"
* message. Mirrors the operator's legacy SMS: greeting, route, train/seat line(s), travel
* times, pay link, and the 2-hour pay-window warning (enforced by tasks.service — see
* MAX_PAYMENT_HOURS). The body comes from the editable `booking.created` template; the shallow
* event payload is re-fetched with schedule + seats to fill it.
*/
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
const booking = payload.booking;
await this.send(
'booking.created',
booking.passengerId,
{
bookingRef: booking.bookingRef,
amount: this.formatAmount(booking),
currency: booking.displayCurrency ?? 'ETB',
category: 'BOOKING',
deepLink: `edr://bookings/${booking.bookingRef}`,
const bookingId = payload.booking.id;
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
},
// For now, always notify the travelling passenger on every channel.
['IN_APP', 'EMAIL', 'SMS'],
);
});
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: 'booking.created' },
});
if (!template || !template.active) {
this.logger.warn('booking.created template not found or inactive');
return;
}
const { subject, body } = this.interpolate(template, this.buildBookingCreatedContext(booking, ref));
// IN_APP — always created.
await this.createInAppNotification(passengerId, subject, body, {
category: 'BOOKING',
deepLink: `edr://bookings/${ref}`,
});
// SMS — the primary channel for this message. Prefer the IAM user's number, fall back to
// the phone entered on the booking form (guest bookings have no IAM user).
const contactPhone: string | null =
(booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null;
const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null;
const smsPhone = iamPhone ?? contactPhone;
if (smsPhone) {
await this.smsClient
.sendSms({ to: smsPhone, message: body })
.catch((e) => this.logger.error(`booking.created SMS failed for ${ref}: ${e?.message}`));
} else {
this.logger.warn(`No SMS phone for booking ${ref}`);
}
// EMAIL — same text, with the same contact fallback.
const contactEmail: string | null =
(booking as any)?.contactEmail ?? (payload.booking as any)?.contactEmail ?? null;
const iamEmail = passengerId ? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null) : null;
const emailTo = iamEmail ?? contactEmail;
if (emailTo) {
await this.emailClient
.sendEmail({ to: emailTo, subject, text: body })
.catch((e) => this.logger.error(`booking.created email failed for ${ref}: ${e?.message}`));
}
}
/**
* Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
* pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
* several lines).
*/
private buildBookingCreatedContext(booking: any, ref: string): Record<string, unknown> {
const s = booking?.schedule ?? {};
const trainName = s.train?.name ?? s.train?.number ?? '';
const fmtDate = (d: any) =>
d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD';
const fmtTime = (d: any) =>
d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD';
const seats = booking?.seats ?? [];
const trainSeatLines = seats
.map((bs: any) => {
const coach = bs.seat?.coach?.number ?? '-';
const cls = bs.seat?.coach?.coachType?.name ?? '';
const seatNo = bs.seat?.seatNumber ?? '-';
return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim();
})
.join('\n');
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
const passengerName = seats[0]?.passengerName ?? 'Passenger';
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
return {
passengerName,
bookingRef: ref,
origin: s.originStation?.name ?? '',
destination: s.destinationStation?.name ?? '',
trainSeatLines,
travelDate: fmtDate(s.departureAt),
departureTime: fmtTime(s.departureAt),
arrivalTime: fmtTime(s.arrivalAt),
payLink,
};
}
/**