From 0c3050b6fe34cc8df775b980b3eb12f980619681 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 8 Jul 2026 15:40:31 +0300 Subject: [PATCH 1/2] feat: ( notification ) add notification management --- .../notifications/notifications.controller.ts | 35 +++- .../notifications/notifications.dto.ts | 55 +++++- .../notifications/notifications.service.ts | 158 ++++++++++++++++-- .../backoffice/src/app/notifications/page.tsx | 150 +++++++++++++---- 4 files changed, 350 insertions(+), 48 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts index f27bd8ecd..99ce32b08 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts index e55535a2a..4b2050de1 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts @@ -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; } +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() diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 43d1e4d46..fc8bffa27 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -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 { + 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, + }; } /** diff --git a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx index 049a12237..d277eb3de 100644 --- a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx @@ -10,8 +10,12 @@ import ActionButton from '@/components/ui/ActionButton'; import { notificationsApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP']; + export default function NotificationsPage() { const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + const [templateError, setTemplateError] = useState(null); const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'history'>('templates'); const [sendForm, setSendForm] = useState({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' }); const [sendError, setSendError] = useState(null); @@ -34,10 +38,54 @@ export default function NotificationsPage() { mutationFn: notificationsApi.createTemplate, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notification-templates'] }); - setShowModal(false); + closeTemplateModal(); }, + onError: (e: any) => setTemplateError(e?.response?.data?.message || e?.message || 'Failed to create template'), }); + const updateTemplateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => notificationsApi.updateTemplate(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notification-templates'] }); + closeTemplateModal(); + }, + onError: (e: any) => setTemplateError(e?.response?.data?.message || e?.message || 'Failed to update template'), + }); + + const openCreateModal = () => { + setEditing(null); + setTemplateError(null); + setShowModal(true); + }; + + const openEditModal = (template: any) => { + setEditing(template); + setTemplateError(null); + setShowModal(true); + }; + + const closeTemplateModal = () => { + setShowModal(false); + setEditing(null); + setTemplateError(null); + }; + + const handleTemplateSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setTemplateError(null); + const fd = new FormData(e.currentTarget); + const channel = fd.get('channel') as string; + const subject = (fd.get('subject') as string) || undefined; + const bodyTemplate = fd.get('bodyTemplate') as string; + const active = fd.get('active') === 'on'; + + if (editing) { + await updateTemplateMutation.mutateAsync({ id: editing.id, data: { channel, subject, bodyTemplate, active } }); + } else { + await createTemplateMutation.mutateAsync({ code: fd.get('code') as string, channel, subject, bodyTemplate, active }); + } + }; + const sendMutation = useMutation({ mutationFn: notificationsApi.send, onSuccess: () => { @@ -59,23 +107,36 @@ export default function NotificationsPage() { const historyArray = Array.isArray(historyData) ? historyData : (historyData as any)?.items || []; const templateColumns = [ - { key: 'name', label: 'Template Name', render: (t: any) => {t.name} }, - { key: 'channel', label: 'Channel', render: (t: any) => {t.channel || t.type} }, + { key: 'code', label: 'Code / Event Key', render: (t: any) => {t.code} }, + { key: 'channel', label: 'Channel', render: (t: any) => {t.channel} }, { key: 'subject', label: 'Subject / Body', - render: (t: any) => {t.subject || t.body || t.content || '—'}, + render: (t: any) => ( + + {t.subject ? `${t.subject} — ` : ''}{(t.bodyTemplate || '').replace(/\n/g, ' ') || '—'} + + ), }, { key: 'active', label: 'Status', render: (t: any) => ( - - {t.isActive !== false ? 'Active' : 'Inactive'} + + {t.active !== false ? 'Active' : 'Inactive'} ), }, { key: 'createdAt', label: 'Created', render: (t: any) => {formatDateTime(t.createdAt)} }, + { + key: 'actions', + label: '', + render: (t: any) => ( + + ), + }, ]; const historyColumns = [ @@ -106,7 +167,7 @@ export default function NotificationsPage() {

Manage notification templates and send messages

{activeTab === 'templates' && ( - setShowModal(true)}>New Template + New Template )} @@ -184,43 +245,68 @@ export default function NotificationsPage() { )} - setShowModal(false)} title="Create Notification Template"> -
{ - e.preventDefault(); - const fd = new FormData(e.currentTarget); - await createTemplateMutation.mutateAsync({ - name: fd.get('name') as string, - channel: fd.get('channel') as string, - subject: fd.get('subject') as string, - body: fd.get('body') as string, - }); - }} - className="space-y-4" - > + +
- - + + + {editing && ( +

Code is the event key and cannot be changed.

+ )}
- - + {CHANNEL_OPTIONS.map((c) => ( + + ))} + + +

+ One channel, or a comma-separated list (EMAIL, SMS, PUSH, IN_APP). +

- +
-