feat: ( notifications ) send SMS and email for applied and expired reschedules and upgrades

This commit is contained in:
Abubeker Yasin
2026-09-03 16:09:03 +03:00
parent 9a9955cbaa
commit 38df7f034c
5 changed files with 448 additions and 31 deletions

View File

@@ -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' },

View File

@@ -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();
});
});

View File

@@ -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<string, unknown> {
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<string, unknown>,
): Promise<void> {
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<string, unknown>,
@@ -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')

View File

@@ -433,7 +433,7 @@ export class RescheduleService {
async expireStale(now = new Date()): Promise<number> {
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;
}

View File

@@ -603,7 +603,7 @@ export class UpgradeService {
async expireStale(now = new Date()): Promise<number> {
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;
}