mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 12:33:42 +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.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' },
|
||||
|
||||
@@ -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(
|
||||
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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user