From 42c020c97aac1f11bc588cf646c5767941c333cb Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 19 Jun 2026 06:12:46 +0300 Subject: [PATCH] fix: ( notification ) add notification templet and fix issues --- .../migration.sql | 3 ++ apps/edr-passenger-api/prisma/seed.ts | 19 +++++--- .../modules/notifications/dtos/email.dto.ts | 13 ------ .../notifications/email-client.service.ts | 8 +++- .../notifications/notifications.service.ts | 45 +++++++++++++++---- .../notifications/sms-client.service.ts | 18 +++++++- 6 files changed, 75 insertions(+), 31 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql index 7622faf86..3b5beccb8 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql @@ -141,6 +141,9 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; -- AlterTable +-- gender is created here on a clean migration history (no prior migration adds it); +-- on an already-drifted DB where it exists as varchar, normalize it to TEXT. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT; ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT; -- CreateIndex diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 9e1fffa44..bac8a4dd4 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -550,18 +550,22 @@ async function seedSegmentFares() { async function seedNotificationTemplates() { console.log('\nšŸ”” Seeding notification templates...'); + // NOTE: `code` must match the templateKey passed by NotificationsService.send(...). + // The event-driven handlers use the dotted event names (booking.created, payment.succeeded). const templates = [ - { id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' }, - { id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' }, - { id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', 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' }, - { id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' }, + { 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}}.' }, + // 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' }, + { id: uuidv4(), code: 'promotion.offer', channel: 'PUSH', subject: 'Special Offer', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' }, ]; for (const t of templates) { await prisma.notificationTemplate.upsert({ where: { code: t.code }, - update: {}, + // Refresh the editable fields on re-seed so template tweaks actually take effect. + update: { channel: t.channel, subject: t.subject ?? null, bodyTemplate: t.bodyTemplate, active: true }, create: t, }); } @@ -684,7 +688,8 @@ async function main() { ['system users', seedSystemUsers], ['fare rules', seedFareRules], ['segment fares', seedSegmentFares], - ['currency', seedCurrency] + ['currency', seedCurrency], + ['notification templates', seedNotificationTemplates] ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts index a435fa4ce..c8fbb74c9 100644 --- a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts +++ b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts @@ -40,18 +40,5 @@ export class SendEmail { @IsOptional() context?: Record; - @ApiPropertyOptional() - @IsOptional() - @IsString() - templateName?: string; - @ApiPropertyOptional() - @IsOptional() - @IsEmail() - from?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsEmail() - replyTo?: string; } 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 34879ed0a..d2181206a 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 @@ -29,11 +29,17 @@ export class EmailClientService implements OnApplicationBootstrap { } async sendEmail(dto: SendEmail) { - if (!this.enabled) return {}; + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to ${dto.to}`); + return {}; + } this.emailServiceClient.emit("send-email", { ...dto, appKey: "IFHCRS-LICENSE-MANAGEMENT", }); + 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 ?? ""}"`, + ); return {}; } } 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 595633e93..1af7c7952 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -169,7 +169,12 @@ export class NotificationsService { private async getUserPreferredChannels(recipient: string): Promise { const user = await this.prisma.user.findFirst({ where: { - OR: [{ id: recipient }, { email: recipient }, { phone: recipient }], + OR: [ + { id: recipient }, + { email: recipient }, + { phone: recipient }, + { passenger: { id: recipient } }, + ], }, include: { preferences: true }, }); @@ -192,7 +197,12 @@ export class NotificationsService { ): Promise { const user = await this.prisma.user.findFirst({ where: { - OR: [{ id: recipient }, { email: recipient }, { phone: recipient }], + OR: [ + { id: recipient }, + { email: recipient }, + { phone: recipient }, + { passenger: { id: recipient } }, + ], }, }); @@ -239,27 +249,46 @@ export class NotificationsService { @OnEvent('booking.created') async onBookingCreated(payload: any) { + const booking = payload.booking; await this.send( 'booking.created', - payload.booking.passengerId, + booking.passengerId, { - bookingRef: payload.booking.bookingRef, + bookingRef: booking.bookingRef, + amount: this.formatAmount(booking), + currency: booking.displayCurrency ?? 'ETB', category: 'BOOKING', - deepLink: `edr://bookings/${payload.booking.bookingRef}`, + deepLink: `edr://bookings/${booking.bookingRef}`, }, + // For now, always notify the travelling passenger on every channel. + ['IN_APP', 'EMAIL', 'SMS'], ); } @OnEvent('payment.succeeded') async onPaymentSucceeded(payload: any) { + const booking = payload.booking; await this.send( 'payment.succeeded', - payload.booking.passengerId, + booking.passengerId, { - bookingRef: payload.booking.bookingRef, + bookingRef: booking.bookingRef, + amount: this.formatAmount(booking), + currency: booking.displayCurrency ?? 'ETB', category: 'PAYMENT', - deepLink: `edr://tickets/${payload.booking.bookingRef}`, + deepLink: `edr://tickets/${booking.bookingRef}`, }, + ['IN_APP', 'EMAIL', 'SMS'], ); } + + /** + * Formats a booking's payable amount from minor units into a major-unit string. + * Money is stored as integer minor units (e.g. 59600 santim) to avoid floating-point + * drift; we divide by 100 only here, at the display edge. e.g. 59600 -> "596.00". + */ + private formatAmount(booking: any): string { + const minor = booking.displayTotalMinor ?? booking.totalMinor ?? 0; + return (minor / 100).toFixed(2); + } } \ No newline at end of file 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 ef2758686..6ef0a99ac 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 @@ -31,20 +31,34 @@ export class SmsClientService implements OnApplicationBootstrap { } async sendSms(dto: SingleMessageDto) { - if (!this.enabled) return {}; + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); + return {}; + } this.smsClient.emit("send-sms", { ...dto, 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}"`, + ); return {}; } async sendBulkMessages(dto: BulkMessagesDto) { - if (!this.enabled) return {}; + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); + return {}; + } this.smsClient.emit("ozeking-bulk-sms", { ...dto, 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 })), + )}`, + ); return {}; } }