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

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