From 525e724461db51b373d4bd0d48da8687571b93c6 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 10:19:11 +0300 Subject: [PATCH 1/4] fix: ( payment ) pass major amount to the payment provider --- .../src/modules/notifications/sms-client.service.ts | 6 ++++-- .../src/modules/payments/payments.service.ts | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index 6ef0a99ac..a71ee882d 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -35,12 +35,14 @@ export class SmsClientService implements OnApplicationBootstrap { this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); return {}; } + // The external send-sms consumer reads the content from `sms`, not `message`. this.smsClient.emit("send-sms", { - ...dto, + to: dto.to, + sms: dto.message, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); this.logger.log( - `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} message="${dto.message}"`, + `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} sms="${dto.message}"`, ); return {}; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index c8d0580bf..871105b31 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -137,7 +137,9 @@ export class PaymentsService { referenceType: PaymentReferenceType.BOOKING, referenceId: booking.id, orderRef: booking.bookingRef, - amountMinor: booking.totalMinor, + // Send the REAL (major) price, not minor units. The payment API no longer divides by 100 + // (freight already passes the real price), so the providers charge this value as-is. + amountMinor: booking.totalMinor / 100, currency: booking.currency, provider: method as unknown as ProviderMethod, platform: dto.platform, @@ -634,11 +636,15 @@ export class PaymentsService { return { processed: false, reason: "booking-not-found" }; } - if (booking.totalMinor !== event.amountMinor) { + // The event carries the REAL (major) price the provider charged (passenger now sends + // booking.totalMinor/100 on initiate), so convert it back to minor units before comparing + // with booking.totalMinor (which is in minor units). + const eventAmountMinor = Math.round(event.amountMinor * 100); + if (booking.totalMinor !== eventAmountMinor) { // Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED, // which is the alertable signal for an asserted-vs-paid amount divergence. this.logger.error( - `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`, + `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`, ); throw new BadRequestException( "Event amount does not match booking total", From a2b2ed787e1fef7cd71203b9191c6b3ba15e7960 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 11:17:56 +0300 Subject: [PATCH 2/4] fix: ( sms ) change the key sms to text --- .../src/modules/notifications/sms-client.service.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index a71ee882d..5421a6efa 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -35,14 +35,13 @@ export class SmsClientService implements OnApplicationBootstrap { this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); return {}; } - // The external send-sms consumer reads the content from `sms`, not `message`. this.smsClient.emit("send-sms", { to: dto.to, - sms: dto.message, + text: dto.message, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); this.logger.log( - `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} sms="${dto.message}"`, + `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} text="${dto.message}"`, ); return {}; } @@ -52,14 +51,13 @@ export class SmsClientService implements OnApplicationBootstrap { this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); return {}; } + const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from })); this.smsClient.emit("ozeking-bulk-sms", { - ...dto, + messages, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); this.logger.log( - `BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${dto.messages?.length ?? 0} messages=${JSON.stringify( - (dto.messages ?? []).map((m) => ({ to: m.to, message: m.message })), - )}`, + `BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} messages=${JSON.stringify(messages)}`, ); return {}; } From fcbc59e95b268aa9fbe1bd327f44b7350407a800 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 11:41:31 +0300 Subject: [PATCH 3/4] refactor: ( notifications ) remove dead SendGrid/Twilio adapters and legacy code --- apps/edr-passenger-api/package.json | 1 - .../notifications/notification.adapters.ts | 189 ------------------ .../notifications/notifications.module.ts | 6 +- .../notifications/notifications.service.ts | 38 ---- pnpm-lock.yaml | 35 ---- 5 files changed, 1 insertion(+), 268 deletions(-) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 84ea472d4..8ef7e669a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -34,7 +34,6 @@ "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", "@prisma/client": "^6.19.3", - "@sendgrid/mail": "^8.1.0", "axios": "^1.7.7", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", diff --git a/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts b/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts index c7db1a0a4..b7df05276 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts @@ -1,199 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import * as sgMail from '@sendgrid/mail'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom } from 'rxjs'; export interface NotificationChannel { send(recipient: string, subject: string, body: string, context?: Record): Promise; } -@Injectable() -export class EmailAdapter implements NotificationChannel { - private readonly logger = new Logger(EmailAdapter.name); - - constructor(private readonly config: ConfigService) { - const apiKey = this.config.get('SENDGRID_API_KEY'); - if (apiKey) { - sgMail.setApiKey(apiKey); - this.logger.log('SendGrid Email adapter initialized'); - } else { - this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only'); - } - } - - async send( - recipient: string, - subject: string, - body: string, - context?: Record, - ): Promise { - const apiKey = this.config.get('SENDGRID_API_KEY'); - const fromEmail = this.config.get('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com'; - - if (!apiKey) { - this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`); - return true; - } - - try { - const msg: sgMail.MailDataRequired = { - to: recipient, - from: fromEmail, - subject, - text: body, - html: this.formatHtml(body, context), - }; - - await sgMail.send(msg); - this.logger.log(`Email sent successfully to ${recipient}`); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Failed to send email to ${recipient}: ${message}`); - return false; - } - } - - private formatHtml(body: string, context?: Record): string { - const contextHtml = context - ? `
- ${JSON.stringify(context, null, 2)} -
` - : ''; - - return ` - - - - - - - -
-
-

Ethio-Djibouti Railway

-
-
- ${body.replace(/\n/g, '
')} - ${contextHtml} -
- -
- - - `; - } -} - -@Injectable() -export class SmsAdapter implements NotificationChannel { - private readonly logger = new Logger(SmsAdapter.name); - - constructor( - private readonly config: ConfigService, - private readonly http: HttpService, - ) { - const provider = this.config.get('SMS_PROVIDER'); - this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`); - } - - async send( - recipient: string, - subject: string, - body: string, - _context?: Record, - ): Promise { - const provider = this.config.get('SMS_PROVIDER'); - const apiKey = this.config.get('SMS_API_KEY'); - - if (!provider || !apiKey) { - this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`); - return true; - } - - try { - switch (provider.toLowerCase()) { - case 'twilio': - return await this.sendViaTwilio(recipient, body); - case 'africastalking': - return await this.sendViaAfricasTalking(recipient, body); - default: - this.logger.warn(`Unknown SMS provider: ${provider}`); - return false; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Failed to send SMS to ${recipient}: ${message}`); - return false; - } - } - - private async sendViaTwilio(to: string, body: string): Promise { - const accountSid = this.config.get('TWILIO_ACCOUNT_SID'); - const authToken = this.config.get('TWILIO_AUTH_TOKEN'); - const fromNumber = this.config.get('TWILIO_FROM_NUMBER'); - - const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`; - const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64'); - - const response = await firstValueFrom( - this.http.post( - url, - new URLSearchParams({ - To: to, - From: fromNumber || '', - Body: body, - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': `Basic ${auth}`, - }, - }, - ), - ); - - return response.status === 201; - } - - private async sendViaAfricasTalking(to: string, body: string): Promise { - const apiKey = this.config.get('SMS_API_KEY'); - const username = this.config.get('AFRICASTALKING_USERNAME'); - const from = this.config.get('AFRICASTALKING_FROM'); - - const url = 'https://api.africastalking.com/version1/messaging'; - - const response = await firstValueFrom( - this.http.post( - url, - new URLSearchParams({ - username: username || '', - to, - message: body, - from: from || '', - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'apiKey': apiKey || '', - }, - }, - ), - ); - - return response.status === 201; - } -} - @Injectable() export class PushAdapter implements NotificationChannel { private readonly logger = new Logger(PushAdapter.name); diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index c72552015..0ee178a03 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,15 +1,13 @@ import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; -import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters'; +import { PushAdapter } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; @Module({ imports: [ - HttpModule.register({ timeout: 10_000 }), ClientsModule.register([ { name: 'EMAIL_SERVICE', @@ -34,8 +32,6 @@ import { SmsClientService } from './sms-client.service'; controllers: [NotificationsController], providers: [ NotificationsService, - EmailAdapter, - SmsAdapter, PushAdapter, EmailClientService, SmsClientService, 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 1af7c7952..3f4e25984 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { PrismaService } from '../../common/prisma.service'; -import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto'; import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; @@ -83,37 +82,6 @@ export class NotificationsService { 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.emailClient.sendEmail({ - to: passenger.user.email, - subject: this.sanitize(dto.title), - text: this.sanitize(dto.body), - }); - } - - return notification; - } - private async createInAppNotification( recipient: string, title: string, @@ -221,12 +189,6 @@ export class NotificationsService { } } - 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 }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1b29edac..59c0e1d63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -452,9 +452,6 @@ importers: '@prisma/client': specifier: ^6.19.3 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) - '@sendgrid/mail': - specifier: ^8.1.0 - version: 8.1.6 axios: specifier: ^1.7.7 version: 1.17.0 @@ -3756,18 +3753,6 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sendgrid/client@8.1.6': - resolution: {integrity: sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==} - engines: {node: '>=12.*'} - - '@sendgrid/helpers@8.0.0': - resolution: {integrity: sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==} - engines: {node: '>= 12.0.0'} - - '@sendgrid/mail@8.1.6': - resolution: {integrity: sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==} - engines: {node: '>=12.*'} - '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -16642,26 +16627,6 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sendgrid/client@8.1.6': - dependencies: - '@sendgrid/helpers': 8.0.0 - axios: 1.17.0 - transitivePeerDependencies: - - debug - - supports-color - - '@sendgrid/helpers@8.0.0': - dependencies: - deepmerge: 4.3.1 - - '@sendgrid/mail@8.1.6': - dependencies: - '@sendgrid/client': 8.1.6 - '@sendgrid/helpers': 8.0.0 - transitivePeerDependencies: - - debug - - supports-color - '@sinclair/typebox@0.27.10': {} '@sindresorhus/merge-streams@4.0.0': {} From 2288a9efda1086911d5adf1188a6aa65a054f3d0 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 15:49:30 +0300 Subject: [PATCH 4/4] feat: ( notifications ) add payment/cancellation events and ticket confirmation --- apps/edr-passenger-api/prisma/seed.ts | 2 + .../src/modules/bookings/bookings.service.ts | 1 + .../notifications/email-client.service.ts | 15 +- .../notifications/notifications.module.ts | 3 + .../notifications/notifications.service.ts | 270 ++++++++++++++++-- .../notifications/sms-client.service.ts | 22 +- .../src/modules/payments/payments.service.ts | 6 + 7 files changed, 280 insertions(+), 39 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index bac8a4dd4..34e5e1dc8 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -555,6 +555,8 @@ async function seedNotificationTemplates() { const templates = [ { id: uuidv4(), code: 'booking.created', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed. Total: {{amount}} {{currency}}.' }, { id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' }, + { id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' }, + { id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' }, // Templates below are not wired to handlers yet (Phase 2 — full event coverage). { id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, { id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b40a5c0ee..44a31c151 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1007,6 +1007,7 @@ export class BookingsService { await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } }); await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } }); + this.eventEmitter.emit('booking.cancelled', { booking, refundAmount }); return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; } diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts index d2181206a..e50ac8037 100644 --- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts @@ -28,18 +28,23 @@ export class EmailClientService implements OnApplicationBootstrap { ); } - async sendEmail(dto: SendEmail) { + async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> { if (!this.enabled) { - this.logger.warn(`RABBITMQ disabled — skipped EMAIL to ${dto.to}`); - return {}; + this.logger.warn(`RABBITMQ disabled — skipped EMAIL`); + return { queued: false }; } this.emailServiceClient.emit("send-email", { ...dto, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); + // Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered. this.logger.log( - `EMAIL emitted to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`, + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, ); - return {}; + // Recipient + content are PII — keep them at debug level only. + this.logger.debug( + `EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`, + ); + return { queued: true }; } } diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index 0ee178a03..c76a7e0bd 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; @@ -8,6 +9,8 @@ import { SmsClientService } from './sms-client.service'; @Module({ imports: [ + // Required by IamGuard (injects HttpService) used in NotificationsController. + HttpModule.register({ timeout: 10_000 }), ClientsModule.register([ { name: 'EMAIL_SERVICE', 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 3f4e25984..0c6add4b2 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -19,8 +19,8 @@ export class NotificationsService { private pushAdapter: PushAdapter, ) { this.channels = new Map([ - ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }], - ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], + ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then((r) => r.queued) }], + ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then((r) => r.queued) }], ['PUSH', this.pushAdapter as NotificationChannel], ]); } @@ -37,27 +37,38 @@ export class NotificationsService { recipient: string, context: Record, channels?: NotificationChannelType[], - ): Promise<{ sent: boolean; channels: string[] }> { + ): Promise<{ queued: 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: [] }; + return { queued: 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'); + // Channel resolution: explicit argument wins; otherwise honor the template's declared + // channel(s); otherwise fall back to the recipient's preferences. + let targetChannels: NotificationChannelType[]; + if (channels) { + targetChannels = channels; + } else if (template.channel) { + targetChannels = this.parseTemplateChannels(template.channel); + } else { + targetChannels = await this.getUserPreferredChannels(recipient); + } + + // Channels successfully handed off (in-app persisted / email+SMS enqueued to RabbitMQ). + // NOTE: enqueue is fire-and-forget — this is NOT a delivery confirmation. + const queuedChannels: string[] = []; + + if (targetChannels.includes('IN_APP')) { + await this.createInAppNotification(recipient, subject, body, context); + queuedChannels.push('IN_APP'); } - // Send via other channels for (const channelType of targetChannels) { if (channelType === 'IN_APP') continue; @@ -73,13 +84,26 @@ export class NotificationsService { continue; } - const success = await adapter.send(recipientAddress, subject, body, context); - if (success) { - sentChannels.push(channelType); + const queued = await adapter.send(recipientAddress, subject, body, context); + if (queued) { + queuedChannels.push(channelType); } } - return { sent: sentChannels.length > 0, channels: sentChannels }; + return { queued: queuedChannels.length > 0, channels: queuedChannels }; + } + + /** + * Parses a template's `channel` column (e.g. "EMAIL" or "EMAIL,SMS") into valid channel + * types, always including IN_APP so an in-app record is created. + */ + private parseTemplateChannels(channel: string): NotificationChannelType[] { + const valid: NotificationChannelType[] = ['EMAIL', 'SMS', 'PUSH', 'IN_APP']; + const parsed = channel + .split(',') + .map((c) => c.trim().toUpperCase()) + .filter((c): c is NotificationChannelType => valid.includes(c as NotificationChannelType)); + return Array.from(new Set(['IN_APP', ...parsed])); } private async createInAppNotification( @@ -122,16 +146,20 @@ export class NotificationsService { template: { subject?: string | null; bodyTemplate: string }, context: Record, ): { subject: string; body: string } { - const subject = template.subject || 'Notification'; - let body = template.bodyTemplate; + return { + subject: this.applyVars(template.subject || 'Notification', context), + body: this.applyVars(template.bodyTemplate, context), + }; + } - // Simple template interpolation: {{variable}} + /** Replaces {{variable}} placeholders in a string with values from the context. */ + private applyVars(text: string, context: Record): string { + let out = text; for (const [key, value] of Object.entries(context)) { const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g'); - body = body.replace(regex, String(value)); + out = out.replace(regex, String(value)); } - - return { subject, body }; + return out; } private async getUserPreferredChannels(recipient: string): Promise { @@ -227,18 +255,210 @@ export class NotificationsService { ); } + /** + * Payment succeeded → one combined "payment successful, here is your ticket" notification. + * Email carries the full ticket (HTML + QR); SMS is a short pointer to view it. The shallow + * event payload is re-fetched with the relations needed to render the ticket. + */ @OnEvent('payment.succeeded') async onPaymentSucceeded(payload: any) { + const passengerId = payload.booking.passengerId; + 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 } } } } } }, + }, + }); + const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } }); + + const ref = booking?.bookingRef ?? payload.booking.bookingRef; + const amount = this.formatAmount(booking ?? payload.booking); + const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB'; + const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`; + + // IN_APP — always created. + await this.createInAppNotification( + passengerId, + 'Payment successful', + `Your payment of ${amount} ${currency} for booking ${ref} was successful. Your ticket is ready.`, + { category: 'PAYMENT', deepLink: `edr://tickets/${ref}` }, + ); + + // Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation. + if (!ticket || !booking) { + this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`); + const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`; + await this.deliverEmail(passengerId, `Payment received — ${ref}`, text); + await this.deliverSms(passengerId, text); + return; + } + + // SMS — short pointer (no HTML/QR over SMS). + await this.deliverSms( + passengerId, + `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, + ); + + // EMAIL — rich HTML ticket with plain-text fallback. + await this.deliverEmail( + passengerId, + `Your EDR ticket — ${ref}`, + this.buildTicketEmailText(booking, amount, currency, ticketUrl), + this.buildTicketEmailHtml(booking, ticket, amount, currency, ticketUrl), + ); + } + + private async deliverEmail(recipient: string, subject: string, text: string, html?: string): Promise { + const to = await this.getRecipientAddress(recipient, 'EMAIL'); + if (!to) { + this.logger.warn(`No EMAIL address for recipient: ${recipient}`); + return; + } + await this.emailClient.sendEmail({ to, subject, text, html }); + } + + private async deliverSms(recipient: string, message: string): Promise { + const to = await this.getRecipientAddress(recipient, 'SMS'); + if (!to) { + this.logger.warn(`No SMS address for recipient: ${recipient}`); + return; + } + await this.smsClient.sendSms({ to, message }); + } + + private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string { + const s = booking.schedule ?? {}; + const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD'; + const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', '); + return [ + `Booking ${booking.bookingRef} confirmed.`, + `${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`, + `Train: ${s.train?.name ?? s.train?.number ?? ''}`, + `Departs: ${dep}`, + passengers ? `Passengers: ${passengers}` : '', + `Total paid: ${amount} ${currency}`, + `View your ticket: ${url}`, + ].filter(Boolean).join('\n'); + } + + private buildTicketEmailHtml(booking: any, ticket: any, amount: string, currency: string, url: string): string { + const s = booking.schedule ?? {}; + const fmt = (d: any) => + d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; + const seatRows = (booking.seats ?? []) + .map((bs: any) => { + const coach = bs.seat?.coach?.number ?? '-'; + const seatNo = bs.seat?.seatNumber ?? '-'; + const cls = bs.seat?.coach?.coachType?.name ?? '-'; + return ` + ${bs.passengerName ?? ''} + ${coach} + ${seatNo} + ${cls} + `; + }) + .join(''); + + return ` + + + +
+
+

Ethio-Djibouti Railway

+

Payment successful — your ticket is ready

+
+
+

Booking reference: ${booking.bookingRef}

+ + + + + + + + + + + + + + + + + + + + + +
From${s.originStation?.name ?? ''} (${s.originStation?.code ?? ''})
To${s.destinationStation?.name ?? ''} (${s.destinationStation?.code ?? ''})
Train${s.train?.name ?? s.train?.number ?? ''}
Departs${fmt(s.departureAt)}
Arrives${fmt(s.arrivalAt)}
+ +

Passengers

+ + + + + + + + ${seatRows} +
NameCoachSeatClass
+ +
+

Show this QR code at the gate

+ Ticket QR code +
+ + + + + + +
Total paid${amount} ${currency}
+ + +
+
+

© Ethio-Djibouti Railway. All rights reserved.

+
+
+ +`; + } + + @OnEvent('payment.failed') + async onPaymentFailed(payload: any) { const booking = payload.booking; await this.send( - 'payment.succeeded', + 'payment.failed', booking.passengerId, { bookingRef: booking.bookingRef, - amount: this.formatAmount(booking), - currency: booking.displayCurrency ?? 'ETB', category: 'PAYMENT', - deepLink: `edr://tickets/${booking.bookingRef}`, + deepLink: `edr://bookings/${booking.bookingRef}`, + }, + ['IN_APP', 'EMAIL', 'SMS'], + ); + } + + @OnEvent('booking.cancelled') + async onBookingCancelled(payload: any) { + const booking = payload.booking; + await this.send( + 'booking.cancelled', + booking.passengerId, + { + bookingRef: booking.bookingRef, + // refundAmount is computed in ETB minor units in BookingsService.cancel(). + refundAmount: ((payload.refundAmount ?? 0) / 100).toFixed(2), + currency: 'ETB', + category: 'BOOKING', + deepLink: `edr://bookings/${booking.bookingRef}`, }, ['IN_APP', 'EMAIL', 'SMS'], ); diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index 5421a6efa..f94c0c20e 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -30,26 +30,29 @@ export class SmsClientService implements OnApplicationBootstrap { }); } - async sendSms(dto: SingleMessageDto) { + async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> { if (!this.enabled) { - this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); - return {}; + this.logger.warn(`RABBITMQ disabled — skipped SMS`); + return { queued: false }; } this.smsClient.emit("send-sms", { to: dto.to, text: dto.message, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. this.logger.log( - `SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} text="${dto.message}"`, + `SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`, ); - return {}; + // Recipient + content are PII — debug only. + this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`); + return { queued: true }; } - async sendBulkMessages(dto: BulkMessagesDto) { + async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> { if (!this.enabled) { this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); - return {}; + return { queued: false }; } const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from })); this.smsClient.emit("ozeking-bulk-sms", { @@ -57,8 +60,9 @@ export class SmsClientService implements OnApplicationBootstrap { appKey: "IFHCRS-LICENSE-MANAGEMENT", }); this.logger.log( - `BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} messages=${JSON.stringify(messages)}`, + `BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`, ); - return {}; + this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`); + return { queued: true }; } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 871105b31..13569ffc4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -622,6 +622,12 @@ export class PaymentsService { failureMessage: event.failureMessage, }); } + const failedBooking = await this.prisma.booking.findUnique({ + where: { id: event.referenceId }, + }); + if (failedBooking) { + this.eventEmitter.emit("payment.failed", { booking: failedBooking }); + } return { processed: true }; }