diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index fb1261daf..3ef232148 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -657,8 +657,70 @@ async function seedNotificationTemplates() { { 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}}.' }, - { id: uuidv4(), code: 'booking.rescheduled', channel: 'EMAIL', subject: 'Booking Rescheduled', bodyTemplate: 'Your {{leg}} journey on booking {{bookingRef}} has been rescheduled. New tickets have been issued. Change fee: {{feeAmount}} {{currency}}.' }, - { id: uuidv4(), code: 'booking.upgraded', channel: 'EMAIL', subject: 'Fare Class Upgraded', bodyTemplate: 'Booking {{bookingRef}}: {{passengerSummary}} upgraded on your {{leg}} journey. New tickets have been issued. Paid: {{amountPaid}} {{currency}}.' }, + // Rich bodies mirroring booking.created: these are delivered by SMS *and* email, so the + // wording has to stand alone in a text message. Channel is 'SMS,EMAIL' — the applied/expired + // handlers deliver directly, but the field keeps the row honest about where it goes. + { id: uuidv4(), code: 'booking.rescheduled', channel: 'SMS,EMAIL', subject: 'Booking Rescheduled', bodyTemplate: `Dear {{passengerName}}, + +Your {{leg}} journey on booking ({{bookingRef}}) has been rescheduled. + +Route: {{origin}} → {{destination}} +{{trainSeatLines}} +{{previousLine}}Travel Date: {{travelDate}} +Departure: {{departureTime}} +Arrival: {{arrivalTime}} + +Paid: {{amountPaid}} {{currency}} (change fee: {{feeAmount}} {{currency}}) +New tickets have been issued. + +View your booking: {{detailLink}} + +Thank you for choosing EDR.` }, + { id: uuidv4(), code: 'booking.upgraded', channel: 'SMS,EMAIL', subject: 'Fare Class Upgraded', bodyTemplate: `Dear {{passengerName}}, + +Your booking ({{bookingRef}}) has been upgraded on the {{leg}} journey. + +Route: {{origin}} → {{destination}} +{{changeLines}} +Travel Date: {{travelDate}} +Departure: {{departureTime}} + +Paid: {{amountPaid}} {{currency}} +New tickets have been issued. + +View your booking: {{detailLink}} + +Thank you for choosing EDR.` }, + { id: uuidv4(), code: 'booking.reschedule.expired', channel: 'SMS,EMAIL', subject: 'Reschedule Request Expired', bodyTemplate: `Dear {{passengerName}}, + +Your reschedule request for booking ({{bookingRef}}) expired before it was paid, so it has not been applied. + +The seat that was being held for it has been released. Your original booking, seats and travel date are unchanged: + +Route: {{origin}} → {{destination}} +{{trainSeatLines}} +Travel Date: {{travelDate}} +Departure: {{departureTime}} + +You can start a new reschedule any time before check-in closes: +{{detailLink}} + +Thank you for choosing EDR.` }, + { id: uuidv4(), code: 'booking.upgrade.expired', channel: 'SMS,EMAIL', subject: 'Upgrade Request Expired', bodyTemplate: `Dear {{passengerName}}, + +Your upgrade request for booking ({{bookingRef}}) expired before it was paid, so it has not been applied. + +The seat that was being held for it has been released. Your original booking and seats are unchanged: + +Route: {{origin}} → {{destination}} +{{trainSeatLines}} +Travel Date: {{travelDate}} +Departure: {{departureTime}} + +You can start a new upgrade any time before check-in closes: +{{detailLink}} + +Thank you for choosing EDR.` }, // 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/notifications/notifications-booking-change.spec.ts b/apps/edr-passenger-api/src/modules/notifications/notifications-booking-change.spec.ts new file mode 100644 index 000000000..204afa6b9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/notifications/notifications-booking-change.spec.ts @@ -0,0 +1,141 @@ +import { NotificationsService } from './notifications.service'; + +/** + * Regression cover for the bug these handlers were written to fix: reschedule/upgrade + * notifications resolved an address only through `iam.users`, where `Passenger.iamUserId` is set + * on under 2% of rows, so EMAIL and SMS were silently skipped on virtually every real booking. + * The handlers must fall back to the contact details the booking itself carries. + */ +describe('booking-change notifications', () => { + const BOOKING = { + id: 'bk-1', + bookingRef: 'NFMRR0', + passengerId: 'pax-1', + bookingType: 'ONE_WAY', + contactPhone: '+251923594242', + contactEmail: 'work.abubeker@gmail.com', + originStationId: 'st-a', + destinationStationId: 'st-b', + schedule: { + departureAt: new Date('2026-09-22T09:00:00Z'), + arrivalAt: new Date('2026-09-22T18:00:00Z'), + originStation: { id: 'st-a', name: 'Sebeta' }, + destinationStation: { id: 'st-b', name: 'Dire Dawa' }, + stopTimes: [], + }, + seats: [ + { + leg: 1, + passengerName: 'Abubeker Yasin', + seat: { seatNumber: '3', coach: { number: 'VIP-0001', coachType: { name: 'VIP Seat' } } }, + }, + ], + }; + + const TEMPLATE = { + code: 'booking.upgraded', + subject: 'Fare Class Upgraded', + bodyTemplate: + 'Dear {{passengerName}},\n{{bookingRef}} {{origin}} → {{destination}}\n{{changeLines}}\nPaid: {{amountPaid}} {{currency}}\n{{detailLink}}', + active: true, + }; + + function build(opts: { iamAddress?: string | null; template?: any; booking?: any } = {}) { + const sms = jest.fn().mockResolvedValue({ queued: true }); + const email = jest.fn().mockResolvedValue({ queued: true }); + const svc: any = Object.create(NotificationsService.prototype); + svc.prisma = { + booking: { findUnique: jest.fn().mockResolvedValue(opts.booking === undefined ? BOOKING : opts.booking) }, + notificationTemplate: { + findUnique: jest.fn().mockResolvedValue(opts.template === undefined ? TEMPLATE : opts.template), + }, + }; + svc.smsClient = { sendSms: sms }; + svc.emailClient = { sendEmail: email }; + svc.logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + // The live condition: IAM knows nothing about this passenger. + svc.getRecipientAddress = jest.fn().mockResolvedValue(opts.iamAddress ?? null); + svc.createInAppNotification = jest.fn().mockResolvedValue(undefined); + return { svc, sms, email }; + } + + const upgradePayload = { + booking: { id: 'bk-1' }, + upgrade: { + leg: 1, + feeMinor: 0, + fareDifferenceMinor: 70000, + items: [ + { passengerName: 'Abubeker Yasin', oldSeatLabel: 'RS-0002 seat 5', newSeatLabel: 'VIP-0001 seat 3' }, + ], + }, + }; + + it('sends SMS and email via the booking contacts when IAM resolves nothing', async () => { + const { svc, sms, email } = build(); + await svc.onBookingUpgraded(upgradePayload); + + expect(sms).toHaveBeenCalledTimes(1); + expect(sms.mock.calls[0][0].to).toBe('+251923594242'); + expect(email).toHaveBeenCalledTimes(1); + expect(email.mock.calls[0][0].to).toBe('work.abubeker@gmail.com'); + expect(email.mock.calls[0][0].subject).toBe('Fare Class Upgraded'); + }); + + it('renders the old → new seat line and the amount paid', async () => { + const { svc, sms } = build(); + await svc.onBookingUpgraded(upgradePayload); + + const body = sms.mock.calls[0][0].message; + expect(body).toContain('Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3'); + expect(body).toContain('Paid: 700.00 ETB'); + expect(body).toContain('Sebeta → Dire Dawa'); + expect(body).not.toContain('{{'); // every placeholder interpolated + }); + + it('prefers the IAM address when there is one', async () => { + const { svc, sms } = build({ iamAddress: '+251900000000' }); + await svc.onBookingUpgraded(upgradePayload); + expect(sms.mock.calls[0][0].to).toBe('+251900000000'); + }); + + it('a failing SMS gateway does not suppress the email', async () => { + const { svc, sms, email } = build(); + sms.mockRejectedValue(new Error('gateway down')); + await svc.onBookingUpgraded(upgradePayload); + expect(email).toHaveBeenCalledTimes(1); + expect(svc.logger.warn).toHaveBeenCalled(); + }); + + it('sends nothing and does not throw when the booking has no contacts', async () => { + const { svc, sms, email } = build({ booking: { ...BOOKING, contactPhone: null, contactEmail: null } }); + await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined(); + expect(sms).not.toHaveBeenCalled(); + expect(email).not.toHaveBeenCalled(); + }); + + it('a missing template is logged, not thrown', async () => { + const { svc, sms } = build({ template: null }); + await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined(); + expect(sms).not.toHaveBeenCalled(); + expect(svc.logger.warn).toHaveBeenCalledWith(expect.stringContaining('not found or inactive')); + }); + + it('expiry notification sends and never throws', async () => { + const { svc, sms, email } = build({ + template: { ...TEMPLATE, code: 'booking.upgrade.expired', subject: 'Upgrade Request Expired' }, + }); + await svc.onUpgradeExpired({ + bookingId: 'bk-1', + request: { leg: 1, amountDueMinor: 70000, items: upgradePayload.upgrade.items }, + }); + expect(sms).toHaveBeenCalledTimes(1); + expect(email.mock.calls[0][0].subject).toBe('Upgrade Request Expired'); + }); + + it('a booking that vanished is logged, not thrown', async () => { + const { svc, sms } = build({ booking: null }); + await expect(svc.onUpgradeExpired({ bookingId: 'gone', request: {} })).resolves.toBeUndefined(); + expect(sms).not.toHaveBeenCalled(); + }); +}); 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 4dc168d00..ded721c8a 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -148,6 +148,180 @@ export class NotificationsService { }); } + + // ── Booking-change notification helpers ────────────────────────────────── + + /** Relations the change templates render: station names, coach number and coach-type name. */ + private static readonly CHANGE_INCLUDE = { + schedule: { + include: { + originStation: true, + destinationStation: true, + train: true, + stopTimes: { include: { station: true } }, + }, + }, + seats: { + include: { seat: { include: { coach: { include: { coachType: true } } } } }, + orderBy: { leg: 'asc' as const }, + }, + }; + + private fmtDate(d: any): string { + return d + ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) + : 'TBD'; + } + + private fmtTime(d: any): string { + return d + ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) + : 'TBD'; + } + + /** Minor units → major, 2dp. Charges are raised in ETB, so no conversion applies. */ + private fmtMinor(minor: number): string { + return ((minor ?? 0) / 100).toFixed(2); + } + + /** + * "Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3", one line per upgraded passenger. + * Labels come off `BookingUpgrade.items`, which snapshots them at quote time — so the message + * still reads correctly even after the seats have moved. + */ + private buildUpgradeChangeLines(items: any[]): string { + return (items ?? []) + .map((i) => { + const who = String(i?.passengerName ?? '').trim(); + const from = String(i?.oldSeatLabel ?? '').trim() || 'previous seat'; + const to = String(i?.newSeatLabel ?? '').trim() || 'new seat'; + return `${who ? `${who}: ` : ''}${from} → ${to}`; + }) + .join('\n'); + } + + /** + * Delivery addresses for a booking-change message. IAM first so a registered passenger's + * current details win, then the contact the booking itself carries — which is the only address + * a guest booking ever has. Mirrors the `iamPhone ?? contactPhone` fallback that + * `onBookingCreated` and `onPaymentSucceeded` already use. + */ + private async resolveDeliveryContacts( + booking: any, + passengerId: string | null, + ): Promise<{ phone: string | null; email: string | null }> { + const iamPhone = passengerId + ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) + : null; + const iamEmail = passengerId + ? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null) + : null; + return { + phone: iamPhone ?? booking?.contactPhone ?? null, + email: iamEmail ?? booking?.contactEmail ?? null, + }; + } + + /** Shared context for every change template: who, where, when, which seats. */ + private buildBookingChangeContext(booking: any, ref: string): Record { + const { passengerName, trainSeatLines } = buildSeatSummary( + booking?.seats ?? [], + booking?.bookingType, + ); + const segment = resolveBookingSegment( + booking?.schedule ?? {}, + booking?.originStationId, + booking?.destinationStationId, + ); + return { + passengerName, + bookingRef: ref, + origin: segment.origin?.name ?? '', + destination: segment.destination?.name ?? '', + trainSeatLines, + travelDate: this.fmtDate(segment.departureAt), + departureTime: this.fmtTime(segment.departureAt), + arrivalTime: this.fmtTime(segment.arrivalAt), + currency: 'ETB', + detailLink: `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`, + }; + } + + /** + * Re-fetch → interpolate → in-app + direct SMS/email. + * + * The event payload is not enough on its own: the reschedule/upgrade services' own + * `bookingInclude` selects `coach: { select: { id, coachTypeId } }` and no station names, so + * buildSeatSummary would render "-, seat no. N". Always read the booking back with + * CHANGE_INCLUDE. + * + * Every failure here is logged and swallowed — a notification must never take down the cron or + * the event emitter that invoked it, and the reschedule/upgrade itself is already committed. + */ + private async notifyBookingChange( + templateCode: string, + bookingId: string, + extra: Record, + ): Promise { + try { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: NotificationsService.CHANGE_INCLUDE as any, + }); + if (!booking) { + this.logger.warn(`${templateCode}: booking ${bookingId} not found — nothing sent`); + return; + } + + const template = await this.prisma.notificationTemplate.findUnique({ + where: { code: templateCode }, + }); + if (!template || !template.active) { + this.logger.warn(`Template ${templateCode} not found or inactive`); + return; + } + + const ref = (booking as any).bookingRef; + const context = { ...this.buildBookingChangeContext(booking, ref), ...extra }; + const { subject, body } = this.interpolate(template, context); + + const passengerId = (booking as any).passengerId ?? null; + if (passengerId) { + await this.createInAppNotification(passengerId, subject, body, { + category: 'BOOKING', + deepLink: `edr://bookings/${ref}`, + }).catch((err) => + this.logger.warn(`${templateCode}: in-app notification failed for ${ref}: ${err}`), + ); + } + + const { phone, email } = await this.resolveDeliveryContacts(booking, passengerId); + if (!phone && !email) { + this.logger.warn(`${templateCode}: no contact details for booking ${ref} — nothing sent`); + return; + } + + // Independent try/catch per channel: a dead SMS gateway must not cost the passenger + // their email too. + if (phone) { + try { + await this.smsClient.sendSms({ to: phone, message: body }); + } catch (err) { + this.logger.warn(`${templateCode}: SMS failed for booking ${ref}: ${err}`); + } + } + if (email) { + try { + await this.emailClient.sendEmail({ to: email, subject, text: body }); + } catch (err) { + this.logger.warn(`${templateCode}: email failed for booking ${ref}: ${err}`); + } + } + } catch (err) { + this.logger.error(`${templateCode}: notification failed for booking ${bookingId}: ${err}`); + } + } + private interpolate( template: { subject?: string | null; bodyTemplate: string }, context: Record, @@ -731,42 +905,68 @@ export class NotificationsService { ); } + /** + * Booking-change notifications (reschedule / upgrade, applied or expired). + * + * These deliberately do NOT go through `send()`. That path resolves an address only via + * `iam.users`, and `Passenger.iamUserId` is set on well under 2% of rows (and can dangle even + * when set), so EMAIL and SMS were silently skipped for almost every real booking while only the + * in-app row was written. They follow `onBookingCreated` instead: re-fetch, interpolate the + * template, then deliver straight to the booking's own contact details. + */ @OnEvent('booking.rescheduled') async onBookingRescheduled(payload: any) { const { booking, reschedule } = payload; - await this.send( - 'booking.rescheduled', - booking.passengerId, - { - bookingRef: booking.bookingRef, - leg: reschedule?.leg === 2 ? 'return' : 'outbound', - feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2), - currency: 'ETB', - category: 'BOOKING', - deepLink: `edr://bookings/${booking.bookingRef}`, - }, - ['IN_APP', 'EMAIL', 'SMS'], - ); + let previousTravelDate = ''; + if (reschedule?.oldScheduleId) { + const old = await this.prisma.trainSchedule + .findUnique({ where: { id: reschedule.oldScheduleId }, select: { departureAt: true } }) + .catch(() => null); + // Pre-formatted so the template never renders a dangling 'Previously:' label. + previousTravelDate = old?.departureAt ? `Previously: ${this.fmtDate(old.departureAt)} +` : ''; + } + await this.notifyBookingChange('booking.rescheduled', booking.id, { + leg: reschedule?.leg === 2 ? 'return' : 'outbound', + previousLine: previousTravelDate, + feeAmount: this.fmtMinor(reschedule?.feeMinor ?? 0), + amountPaid: this.fmtMinor( + (reschedule?.feeMinor ?? 0) + Math.max(0, reschedule?.fareDifferenceMinor ?? 0), + ), + }); } @OnEvent('booking.upgraded') async onBookingUpgraded(payload: any) { const { booking, upgrade } = payload; const items = Array.isArray(upgrade?.items) ? upgrade.items : []; - await this.send( - 'booking.upgraded', - booking.passengerId, - { - bookingRef: booking.bookingRef, - leg: upgrade?.leg === 2 ? 'return' : 'outbound', - passengerSummary: items.map((i: any) => i.passengerName).join(', '), - amountPaid: (((upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0)) / 100).toFixed(2), - currency: 'ETB', - category: 'BOOKING', - deepLink: `edr://bookings/${booking.bookingRef}`, - }, - ['IN_APP', 'EMAIL', 'SMS'], - ); + await this.notifyBookingChange('booking.upgraded', booking.id, { + leg: upgrade?.leg === 2 ? 'return' : 'outbound', + passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '), + changeLines: this.buildUpgradeChangeLines(items), + amountPaid: this.fmtMinor( + (upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0), + ), + }); + } + + @OnEvent('booking.reschedule.expired') + async onRescheduleExpired(payload: any) { + await this.notifyBookingChange('booking.reschedule.expired', payload.bookingId, { + leg: payload.request?.leg === 2 ? 'return' : 'outbound', + amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0), + }); + } + + @OnEvent('booking.upgrade.expired') + async onUpgradeExpired(payload: any) { + const items = Array.isArray(payload.request?.items) ? payload.request.items : []; + await this.notifyBookingChange('booking.upgrade.expired', payload.bookingId, { + leg: payload.request?.leg === 2 ? 'return' : 'outbound', + passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '), + changeLines: this.buildUpgradeChangeLines(items), + amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0), + }); } @OnEvent('booking.cancelled') diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index d45309ac1..e7e9f5f30 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -433,7 +433,7 @@ export class RescheduleService { async expireStale(now = new Date()): Promise { const stale = await this.prisma.bookingReschedule.findMany({ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, - select: { id: true, supplementaryChargeId: true, holdId: true }, + select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true }, }); for (const r of stale) { await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } }); @@ -452,6 +452,13 @@ export class RescheduleService { await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } }); } } + + // After the loop on purpose: the rows are already committed, so a notification failure + // cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors. + for (const s of stale) { + this.eventEmitter.emit('booking.reschedule.expired', { bookingId: s.bookingId, request: s }); + } + return stale.length; } diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts index 95d3aee75..df3c1b5db 100644 --- a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts @@ -603,7 +603,7 @@ export class UpgradeService { async expireStale(now = new Date()): Promise { const stale = await this.prisma.bookingUpgrade.findMany({ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, - select: { id: true, supplementaryChargeId: true, holdId: true }, + select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true, items: true }, }); for (const u of stale) { await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } }); @@ -622,6 +622,13 @@ export class UpgradeService { await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } }); } } + + // After the loop on purpose: the rows are already committed, so a notification failure + // cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors. + for (const s of stale) { + this.eventEmitter.emit('booking.upgrade.expired', { bookingId: s.bookingId, request: s }); + } + return stale.length; }