Files
edr-platform/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
2026-07-08 15:40:31 +03:00

752 lines
31 KiB
TypeScript

import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly channels: Map<NotificationChannelType, NotificationChannel>;
constructor(
private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private emailClient: EmailClientService,
private smsClient: SmsClientService,
private pushAdapter: PushAdapter,
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then((r) => r.queued) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then((r) => r.queued) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}
/**
* Send notification using template key and context
* @param templateKey - Template code from NotificationTemplate table
* @param recipient - User/Passenger ID or email/phone
* @param context - Variables to interpolate in template
* @param channels - Optional array of channels to use (defaults to user preferences)
*/
async send(
templateKey: string,
recipient: string,
context: Record<string, unknown>,
channels?: NotificationChannelType[],
): Promise<{ queued: boolean; channels: string[] }> {
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: templateKey },
});
if (!template || !template.active) {
this.logger.warn(`Template ${templateKey} not found or inactive`);
return { queued: false, channels: [] };
}
const { subject, body } = this.interpolate(template, context);
// Channel resolution: explicit argument wins; otherwise honor the template's declared
// channel(s); otherwise fall back to the recipient's preferences.
let targetChannels: NotificationChannelType[];
if (channels) {
targetChannels = channels;
} else if (template.channel) {
targetChannels = this.parseTemplateChannels(template.channel);
} else {
targetChannels = await this.getUserPreferredChannels(recipient);
}
// Channels successfully handed off (in-app persisted / email+SMS enqueued to RabbitMQ).
// NOTE: enqueue is fire-and-forget — this is NOT a delivery confirmation.
const queuedChannels: string[] = [];
if (targetChannels.includes('IN_APP')) {
await this.createInAppNotification(recipient, subject, body, context);
queuedChannels.push('IN_APP');
}
for (const channelType of targetChannels) {
if (channelType === 'IN_APP') continue;
const adapter = this.channels.get(channelType);
if (!adapter) {
this.logger.warn(`No adapter for channel: ${channelType}`);
continue;
}
const recipientAddress = await this.getRecipientAddress(recipient, channelType);
if (!recipientAddress) {
this.logger.warn(`No ${channelType} address for recipient: ${recipient}`);
continue;
}
const queued = await adapter.send(recipientAddress, subject, body, context);
if (queued) {
queuedChannels.push(channelType);
}
}
return { queued: queuedChannels.length > 0, channels: queuedChannels };
}
/**
* Parses a template's `channel` column (e.g. "EMAIL" or "EMAIL,SMS") into valid channel
* types, always including IN_APP so an in-app record is created.
*/
private parseTemplateChannels(channel: string): NotificationChannelType[] {
const valid: NotificationChannelType[] = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
const parsed = channel
.split(',')
.map((c) => c.trim().toUpperCase())
.filter((c): c is NotificationChannelType => valid.includes(c as NotificationChannelType));
return Array.from(new Set<NotificationChannelType>(['IN_APP', ...parsed]));
}
private async createInAppNotification(
recipient: string,
title: string,
body: string,
context: Record<string, unknown>,
): Promise<void> {
let passengerId = recipient;
if (!UUID_RE.test(recipient)) {
const iamUserId = await this.resolveIamUserId(recipient);
if (!iamUserId) {
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
return;
}
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId } });
if (!passenger) {
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
return;
}
passengerId = passenger.id;
}
await this.prisma.notification.create({
data: {
passengerId,
title,
body,
category: (context.category as any) || 'SYSTEM',
deepLink: context.deepLink as string,
metadata: context as any,
},
});
}
private interpolate(
template: { subject?: string | null; bodyTemplate: string },
context: Record<string, unknown>,
): { subject: string; body: string } {
return {
subject: this.applyVars(template.subject || 'Notification', context),
body: this.applyVars(template.bodyTemplate, context),
};
}
/** Replaces {{variable}} placeholders in a string with values from the context. */
private applyVars(text: string, context: Record<string, unknown>): string {
let out = text;
for (const [key, value] of Object.entries(context)) {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
out = out.replace(regex, String(value));
}
return out;
}
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
const iamUserId = await this.resolveIamUserId(recipient);
const preferences = iamUserId
? await this.prisma.userPreferences.findUnique({ where: { iamUserId } })
: null;
if (!preferences) {
return ['IN_APP', 'EMAIL'];
}
const channels: NotificationChannelType[] = ['IN_APP'];
if (preferences.emailEnabled) channels.push('EMAIL');
if (preferences.smsEnabled) channels.push('SMS');
if (preferences.pushEnabled) channels.push('PUSH');
return channels;
}
private async getRecipientAddress(
recipient: string,
channel: NotificationChannelType,
): Promise<string | null> {
const iamUserId = await this.resolveIamUserId(recipient);
if (!iamUserId) return null;
const contact = await this.resolveContactInfo(iamUserId);
switch (channel) {
case 'EMAIL': return contact.email;
case 'SMS': return contact.phone;
case 'PUSH': return iamUserId;
default: return null;
}
}
private async resolveIamUserId(recipient: string): Promise<string | null> {
if (UUID_RE.test(recipient)) {
const passenger = await this.prisma.passenger.findUnique({ where: { id: recipient } });
return passenger?.iamUserId ?? recipient;
}
const rows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
[recipient],
);
return rows[0]?.id ?? null;
}
private async resolveContactInfo(iamUserId: string): Promise<{ email: string | null; phone: string | null }> {
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
);
return { email: rows[0]?.email ?? null, phone: rows[0]?.phone_number ?? null };
}
private sanitize(value: string): string {
return value
.replace(/[\r\n]/g, ' ')
.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
}
getForPassenger(passengerId: string) {
return this.prisma.notification.findMany({
where: { passengerId },
orderBy: { createdAt: 'desc' },
take: 50,
});
}
markRead(id: string) {
return this.prisma.notification.update({ where: { id }, data: { read: true } });
}
async markAllRead(passengerId: string) {
await this.prisma.notification.updateMany({
where: { passengerId, read: false },
data: { read: true },
});
return { updated: true };
}
// ---------------------------------------------------------------------------
// Template management (backoffice). Templates are keyed by `code`; event
// handlers look them up by that code (e.g. 'booking.created'), so `code` is
// immutable once created — only channel/subject/body/active are editable.
// ---------------------------------------------------------------------------
listTemplates() {
return this.prisma.notificationTemplate.findMany({ orderBy: { code: 'asc' } });
}
async getTemplate(id: string) {
const template = await this.prisma.notificationTemplate.findUnique({ where: { id } });
if (!template) throw new NotFoundException(`Notification template ${id} not found`);
return template;
}
async createTemplate(dto: CreateTemplateDto) {
const existing = await this.prisma.notificationTemplate.findUnique({ where: { code: dto.code } });
if (existing) throw new ConflictException(`Template with code "${dto.code}" already exists`);
return this.prisma.notificationTemplate.create({
data: {
code: dto.code,
channel: dto.channel,
subject: dto.subject ?? null,
bodyTemplate: dto.bodyTemplate,
active: dto.active ?? true,
},
});
}
async updateTemplate(id: string, dto: UpdateTemplateDto) {
await this.getTemplate(id); // 404 if missing
return this.prisma.notificationTemplate.update({
where: { id },
data: {
...(dto.channel !== undefined ? { channel: dto.channel } : {}),
...(dto.subject !== undefined ? { subject: dto.subject } : {}),
...(dto.bodyTemplate !== undefined ? { bodyTemplate: dto.bodyTemplate } : {}),
...(dto.active !== undefined ? { active: dto.active } : {}),
},
});
}
/**
* Booking created (awaiting payment) → the rich "your ticket is booked, here is the pay link"
* message. Mirrors the operator's legacy SMS: greeting, route, train/seat line(s), travel
* times, pay link, and the 2-hour pay-window warning (enforced by tasks.service — see
* MAX_PAYMENT_HOURS). The body comes from the editable `booking.created` template; the shallow
* event payload is re-fetched with schedule + seats to fill it.
*/
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
const bookingId = payload.booking.id;
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
},
});
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: 'booking.created' },
});
if (!template || !template.active) {
this.logger.warn('booking.created template not found or inactive');
return;
}
const { subject, body } = this.interpolate(template, this.buildBookingCreatedContext(booking, ref));
// IN_APP — always created.
await this.createInAppNotification(passengerId, subject, body, {
category: 'BOOKING',
deepLink: `edr://bookings/${ref}`,
});
// SMS — the primary channel for this message. Prefer the IAM user's number, fall back to
// the phone entered on the booking form (guest bookings have no IAM user).
const contactPhone: string | null =
(booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null;
const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null;
const smsPhone = iamPhone ?? contactPhone;
if (smsPhone) {
await this.smsClient
.sendSms({ to: smsPhone, message: body })
.catch((e) => this.logger.error(`booking.created SMS failed for ${ref}: ${e?.message}`));
} else {
this.logger.warn(`No SMS phone for booking ${ref}`);
}
// EMAIL — same text, with the same contact fallback.
const contactEmail: string | null =
(booking as any)?.contactEmail ?? (payload.booking as any)?.contactEmail ?? null;
const iamEmail = passengerId ? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null) : null;
const emailTo = iamEmail ?? contactEmail;
if (emailTo) {
await this.emailClient
.sendEmail({ to: emailTo, subject, text: body })
.catch((e) => this.logger.error(`booking.created email failed for ${ref}: ${e?.message}`));
}
}
/**
* Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
* pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
* several lines).
*/
private buildBookingCreatedContext(booking: any, ref: string): Record<string, unknown> {
const s = booking?.schedule ?? {};
const trainName = s.train?.name ?? s.train?.number ?? '';
const fmtDate = (d: any) =>
d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD';
const fmtTime = (d: any) =>
d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD';
const seats = booking?.seats ?? [];
const trainSeatLines = seats
.map((bs: any) => {
const coach = bs.seat?.coach?.number ?? '-';
const cls = bs.seat?.coach?.coachType?.name ?? '';
const seatNo = bs.seat?.seatNumber ?? '-';
return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim();
})
.join('\n');
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
const passengerName = seats[0]?.passengerName ?? 'Passenger';
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
return {
passengerName,
bookingRef: ref,
origin: s.originStation?.name ?? '',
destination: s.destinationStation?.name ?? '',
trainSeatLines,
travelDate: fmtDate(s.departureAt),
departureTime: fmtTime(s.departureAt),
arrivalTime: fmtTime(s.arrivalAt),
payLink,
};
}
/**
* Payment succeeded → one combined "payment successful, here is your ticket" notification.
* Email carries the full ticket (HTML + QR); SMS is a short pointer to view it. The shallow
* event payload is re-fetched with the relations needed to render the ticket.
*/
@OnEvent('payment.succeeded')
async onPaymentSucceeded(payload: any) {
const passengerId = payload.booking.passengerId;
const bookingId = payload.booking.id;
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
},
});
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId } });
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const amount = this.formatAmount(booking ?? payload.booking);
const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB';
// /booking/confirmation only reads from the in-session booking store, so it's a dead
// link once opened outside that session (a different device, or later on the same
// one) — exactly the case an SMS/email link is for. /booking/detail fetches the
// booking fresh from the API by ref, so it works standalone.
const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
// IN_APP — always created.
await this.createInAppNotification(
passengerId,
'Payment successful',
`Your payment of ${amount} ${currency} for booking ${ref} was successful. Your ticket is ready.`,
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
);
// Resolve SMS phone: prefer the IAM user's stored number, fall back to the phone
// the passenger entered on the booking form (contactPhone).
const contactPhone: string | null = (booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null;
const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null;
const smsPhone = iamPhone ?? contactPhone;
// Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
if (!ticket || !booking) {
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
if (smsPhone) {
await this.smsClient.sendSms({ to: smsPhone, message: text }).catch(() => null);
} else {
this.logger.warn(`No SMS phone for booking ${ref}`);
}
return;
}
// SMS — short pointer (no HTML/QR over SMS).
if (smsPhone) {
await this.smsClient.sendSms({
to: smsPhone,
message: `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
}).catch(() => null);
} else {
this.logger.warn(`No SMS phone for booking ${ref}`);
}
// EMAIL — rich HTML ticket with plain-text fallback.
await this.deliverEmail(
passengerId,
`Your EDR ticket — ${ref}`,
this.buildTicketEmailText(booking, amount, currency, ticketUrl),
this.buildTicketEmailHtml(booking, ticket, amount, currency, ticketUrl),
);
}
private async deliverEmail(recipient: string, subject: string, text: string, html?: string): Promise<void> {
const to = await this.getRecipientAddress(recipient, 'EMAIL');
if (!to) {
this.logger.warn(`No EMAIL address for recipient: ${recipient}`);
return;
}
await this.emailClient.sendEmail({ to, subject, text, html });
}
private async deliverSms(recipient: string, message: string): Promise<void> {
const to = await this.getRecipientAddress(recipient, 'SMS');
if (!to) {
this.logger.warn(`No SMS address for recipient: ${recipient}`);
return;
}
await this.smsClient.sendSms({ to, message });
}
private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
const s = booking.schedule ?? {};
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD';
const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
return [
`Booking ${booking.bookingRef} confirmed.`,
`${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`,
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
`Departs: ${dep}`,
passengers ? `Passengers: ${passengers}` : '',
`Total paid: ${amount} ${currency}`,
`View your ticket: ${url}`,
].filter(Boolean).join('\n');
}
private buildTicketEmailHtml(booking: any, ticket: any, amount: string, currency: string, url: string): string {
const s = booking.schedule ?? {};
const fmt = (d: any) =>
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
const seatRows = (booking.seats ?? [])
.map((bs: any) => {
const coach = bs.seat?.coach?.number ?? '-';
const seatNo = bs.seat?.seatNumber ?? '-';
const cls = bs.seat?.coach?.coachType?.name ?? '-';
return `<tr>
<td style="padding:8px;border-bottom:1px solid #eee;">${bs.passengerName ?? ''}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${coach}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${seatNo}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${cls}</td>
</tr>`;
})
.join('');
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;color:#333;background:#f4f4f4;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#0066cc;color:#fff;padding:24px;text-align:center;">
<h2 style="margin:0;">Ethio-Djibouti Railway</h2>
<p style="margin:8px 0 0;">Payment successful — your ticket is ready</p>
</div>
<div style="padding:24px;">
<p>Booking reference: <strong>${booking.bookingRef}</strong></p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px 0;color:#666;">From</td>
<td style="padding:8px 0;text-align:right;"><strong>${s.originStation?.name ?? ''}</strong> (${s.originStation?.code ?? ''})</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">To</td>
<td style="padding:8px 0;text-align:right;"><strong>${s.destinationStation?.name ?? ''}</strong> (${s.destinationStation?.code ?? ''})</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Train</td>
<td style="padding:8px 0;text-align:right;">${s.train?.name ?? s.train?.number ?? ''}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Departs</td>
<td style="padding:8px 0;text-align:right;">${fmt(s.departureAt)}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Arrives</td>
<td style="padding:8px 0;text-align:right;">${fmt(s.arrivalAt)}</td>
</tr>
</table>
<h3 style="margin:16px 0 8px;">Passengers</h3>
<table style="width:100%;border-collapse:collapse;">
<tr style="text-align:left;color:#666;">
<th style="padding:8px;border-bottom:2px solid #eee;">Name</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Coach</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Seat</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Class</th>
</tr>
${seatRows}
</table>
<div style="text-align:center;margin:24px 0;">
<p style="color:#666;margin:0 0 8px;">Show this QR code at the gate</p>
<img src="${ticket.qrPayload}" alt="Ticket QR code" width="180" height="180" style="border:1px solid #eee;padding:8px;background:#fff;" />
</div>
<table style="width:100%;border-collapse:collapse;border-top:2px solid #eee;margin-top:16px;">
<tr>
<td style="padding:12px 0;font-size:16px;"><strong>Total paid</strong></td>
<td style="padding:12px 0;font-size:16px;text-align:right;"><strong>${amount} ${currency}</strong></td>
</tr>
</table>
<div style="text-align:center;margin:24px 0;">
<a href="${url}" style="background:#0066cc;color:#fff;text-decoration:none;padding:12px 28px;border-radius:4px;display:inline-block;">View ticket</a>
</div>
</div>
<div style="text-align:center;padding:20px;color:#999;font-size:12px;">
<p style="margin:0;">© Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>`;
}
async sendBoardingPassNotification(params: {
passengerId: string | null;
contactEmail: string | null;
contactPhone: string | null;
bookingRef: string;
leg: string | null;
booking: any;
ticket: any;
}): Promise<void> {
const { passengerId, contactEmail, contactPhone, bookingRef, leg, booking, ticket } = params;
// Resolve contact — prefer IAM user record, fall back to booking contact fields
let email: string | null = contactEmail ?? null;
let phone: string | null = contactPhone ?? null;
if (passengerId) {
const resolved = await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null);
const resolvedPhone = await this.getRecipientAddress(passengerId, 'SMS').catch(() => null);
if (resolved) email = resolved;
if (resolvedPhone) phone = resolvedPhone;
}
const s = booking.schedule ?? {};
const fmt = (d: any) =>
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
const origin = s.originStation?.name ?? '';
const dest = s.destinationStation?.name ?? '';
const train = s.train?.name ?? s.train?.number ?? '';
const dep = fmt(s.departureAt);
const arr = fmt(s.arrivalAt);
const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({
name: bs.passengerName ?? '',
coach: bs.seat?.coach?.number ?? '-',
seat: bs.seat?.seatNumber ?? '-',
cls: bs.seat?.coach?.coachType?.name ?? '-',
}));
const seatLines = seats.map(s => ` ${s.name} — Coach ${s.coach}, Seat ${s.seat} (${s.cls})`).join('\n');
const smsText =
`EDR Boarding Pass${legLabel}\n` +
`Ref: ${bookingRef}\n` +
`${origin}${dest}\n` +
`Train: ${train} | Dep: ${dep}\n` +
(seatLines ? `${seatLines}\n` : '') +
`Barcode: ${ticket.barcodePayload}`;
if (phone) {
await this.smsClient.sendSms({ to: phone, message: smsText }).catch((e) =>
this.logger.error(`Boarding pass SMS failed for ${bookingRef}: ${e?.message}`),
);
}
if (email) {
const seatRows = seats
.map(
(s) =>
`<tr>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.name}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.coach}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.seat}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.cls}</td>
</tr>`,
)
.join('');
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;color:#333;background:#f4f4f4;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#0066cc;color:#fff;padding:24px;text-align:center;">
<h2 style="margin:0;">Ethio-Djibouti Railway</h2>
<p style="margin:8px 0 0;">Boarding Pass${legLabel}</p>
</div>
<div style="padding:24px;">
<p>Booking reference: <strong>${bookingRef}</strong></p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr><td style="padding:8px 0;color:#666;">From</td><td style="text-align:right;"><strong>${origin}</strong></td></tr>
<tr><td style="padding:8px 0;color:#666;">To</td><td style="text-align:right;"><strong>${dest}</strong></td></tr>
<tr><td style="padding:8px 0;color:#666;">Train</td><td style="text-align:right;">${train}</td></tr>
<tr><td style="padding:8px 0;color:#666;">Departs</td><td style="text-align:right;">${dep}</td></tr>
<tr><td style="padding:8px 0;color:#666;">Arrives</td><td style="text-align:right;">${arr}</td></tr>
</table>
<h3 style="margin:16px 0 8px;">Passengers</h3>
<table style="width:100%;border-collapse:collapse;">
<tr style="color:#666;text-align:left;">
<th style="padding:8px;border-bottom:2px solid #eee;">Name</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Coach</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Seat</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Class</th>
</tr>
${seatRows}
</table>
<div style="text-align:center;margin:24px 0;">
<p style="color:#666;margin:0 0 8px;">QR code for gate scanning</p>
<img src="${ticket.qrPayload}" alt="Boarding pass QR" width="180" height="180"
style="border:1px solid #eee;padding:8px;background:#fff;" />
<p style="color:#666;font-size:12px;margin:8px 0 0;">Barcode: <strong>${ticket.barcodePayload}</strong></p>
</div>
</div>
<div style="text-align:center;padding:20px;color:#999;font-size:12px;">
<p style="margin:0;">© Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>`;
const textFallback =
`EDR Boarding Pass${legLabel}\nRef: ${bookingRef}\n${origin}${dest}\n` +
`Train: ${train} | Departs: ${dep} | Arrives: ${arr}\n${seatLines}\n` +
`Barcode: ${ticket.barcodePayload}`;
await this.emailClient
.sendEmail({ to: email, subject: `EDR Boarding Pass — ${bookingRef}${legLabel}`, text: textFallback, html })
.catch((e) => this.logger.error(`Boarding pass email failed for ${bookingRef}: ${e?.message}`));
}
}
@OnEvent('payment.failed')
async onPaymentFailed(payload: any) {
const booking = payload.booking;
await this.send(
'payment.failed',
booking.passengerId,
{
bookingRef: booking.bookingRef,
category: 'PAYMENT',
deepLink: `edr://bookings/${booking.bookingRef}`,
},
['IN_APP', 'EMAIL', 'SMS'],
);
}
@OnEvent('booking.cancelled')
async onBookingCancelled(payload: any) {
const booking = payload.booking;
await this.send(
'booking.cancelled',
booking.passengerId,
{
bookingRef: booking.bookingRef,
// refundAmount is computed in ETB minor units in BookingsService.cancel().
refundAmount: ((payload.refundAmount ?? 0) / 100).toFixed(2),
currency: 'ETB',
category: 'BOOKING',
deepLink: `edr://bookings/${booking.bookingRef}`,
},
['IN_APP', 'EMAIL', 'SMS'],
);
}
/**
* Formats a booking's payable amount from minor units into a major-unit string.
* Money is stored as integer minor units (e.g. 59600 santim) to avoid floating-point
* drift; we divide by 100 only here, at the display edge. e.g. 59600 -> "596.00".
*/
private formatAmount(booking: any): string {
const minor = booking.displayTotalMinor ?? booking.totalMinor ?? 0;
return (minor / 100).toFixed(2);
}
}