mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 13:43:43 +00:00
feat: ( notifications ) send SMS and email for applied and expired reschedules and upgrades
This commit is contained in:
@@ -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.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: '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.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}}.' },
|
// Rich bodies mirroring booking.created: these are delivered by SMS *and* email, so the
|
||||||
{ 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}}.' },
|
// 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).
|
// 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.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: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
private interpolate(
|
||||||
template: { subject?: string | null; bodyTemplate: string },
|
template: { subject?: string | null; bodyTemplate: string },
|
||||||
context: Record<string, unknown>,
|
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')
|
@OnEvent('booking.rescheduled')
|
||||||
async onBookingRescheduled(payload: any) {
|
async onBookingRescheduled(payload: any) {
|
||||||
const { booking, reschedule } = payload;
|
const { booking, reschedule } = payload;
|
||||||
await this.send(
|
let previousTravelDate = '';
|
||||||
'booking.rescheduled',
|
if (reschedule?.oldScheduleId) {
|
||||||
booking.passengerId,
|
const old = await this.prisma.trainSchedule
|
||||||
{
|
.findUnique({ where: { id: reschedule.oldScheduleId }, select: { departureAt: true } })
|
||||||
bookingRef: booking.bookingRef,
|
.catch(() => null);
|
||||||
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
|
// Pre-formatted so the template never renders a dangling 'Previously:' label.
|
||||||
feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2),
|
previousTravelDate = old?.departureAt ? `Previously: ${this.fmtDate(old.departureAt)}
|
||||||
currency: 'ETB',
|
` : '';
|
||||||
category: 'BOOKING',
|
}
|
||||||
deepLink: `edr://bookings/${booking.bookingRef}`,
|
await this.notifyBookingChange('booking.rescheduled', booking.id, {
|
||||||
},
|
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
|
||||||
['IN_APP', 'EMAIL', 'SMS'],
|
previousLine: previousTravelDate,
|
||||||
);
|
feeAmount: this.fmtMinor(reschedule?.feeMinor ?? 0),
|
||||||
|
amountPaid: this.fmtMinor(
|
||||||
|
(reschedule?.feeMinor ?? 0) + Math.max(0, reschedule?.fareDifferenceMinor ?? 0),
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('booking.upgraded')
|
@OnEvent('booking.upgraded')
|
||||||
async onBookingUpgraded(payload: any) {
|
async onBookingUpgraded(payload: any) {
|
||||||
const { booking, upgrade } = payload;
|
const { booking, upgrade } = payload;
|
||||||
const items = Array.isArray(upgrade?.items) ? upgrade.items : [];
|
const items = Array.isArray(upgrade?.items) ? upgrade.items : [];
|
||||||
await this.send(
|
await this.notifyBookingChange('booking.upgraded', booking.id, {
|
||||||
'booking.upgraded',
|
leg: upgrade?.leg === 2 ? 'return' : 'outbound',
|
||||||
booking.passengerId,
|
passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '),
|
||||||
{
|
changeLines: this.buildUpgradeChangeLines(items),
|
||||||
bookingRef: booking.bookingRef,
|
amountPaid: this.fmtMinor(
|
||||||
leg: upgrade?.leg === 2 ? 'return' : 'outbound',
|
(upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0),
|
||||||
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}`,
|
@OnEvent('booking.reschedule.expired')
|
||||||
},
|
async onRescheduleExpired(payload: any) {
|
||||||
['IN_APP', 'EMAIL', 'SMS'],
|
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')
|
@OnEvent('booking.cancelled')
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ export class RescheduleService {
|
|||||||
async expireStale(now = new Date()): Promise<number> {
|
async expireStale(now = new Date()): Promise<number> {
|
||||||
const stale = await this.prisma.bookingReschedule.findMany({
|
const stale = await this.prisma.bookingReschedule.findMany({
|
||||||
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
|
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) {
|
for (const r of stale) {
|
||||||
await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
|
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 } });
|
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;
|
return stale.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -603,7 +603,7 @@ export class UpgradeService {
|
|||||||
async expireStale(now = new Date()): Promise<number> {
|
async expireStale(now = new Date()): Promise<number> {
|
||||||
const stale = await this.prisma.bookingUpgrade.findMany({
|
const stale = await this.prisma.bookingUpgrade.findMany({
|
||||||
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
|
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) {
|
for (const u of stale) {
|
||||||
await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } });
|
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 } });
|
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;
|
return stale.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user