Merge branch 'dev' into passenger/feat/iam

This commit is contained in:
Abubeker Yasin
2026-06-22 10:12:39 +03:00
1997 changed files with 435726 additions and 10897 deletions

View File

@@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SendEmail {
@ApiProperty()
@IsEmail()
@IsNotEmpty()
to: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sourceId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sourceName?: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
subject: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
html?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
text?: string;
@ApiPropertyOptional()
@IsOptional()
body?: string;
@ApiPropertyOptional()
@IsOptional()
context?: Record<string, any>;
}

View File

@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class SendMessage {
@ApiProperty()
@IsNotEmpty()
@IsString()
to: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
message: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
from?: string;
}
export class SingleMessageDto {
@ApiProperty({
description: 'Recipient phone number',
example: '+1234567890',
})
@IsString()
@IsNotEmpty()
to: string;
@ApiProperty({
description: 'Message content',
example: 'Test Single SMS from',
})
@IsString()
@IsNotEmpty()
message: string;
}
export class BulkMessagesDto {
@ApiProperty({ type: [SendMessage] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => SendMessage)
messages: SendMessage[];
}

View File

@@ -0,0 +1,50 @@
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { SendEmail } from "./dtos/email.dto";
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name);
constructor(
@Inject("EMAIL_SERVICE")
private readonly emailServiceClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() {
if (!this.enabled) return;
this.emailServiceClient
.connect()
.then(() => this.logger.log("Connected to Email service"))
.catch((err) =>
this.logger.error("Error connecting to Email service", err),
);
}
async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped EMAIL`);
return { queued: false };
}
this.emailServiceClient.emit("send-email", {
...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered.
this.logger.log(
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
);
// Recipient + content are PII — keep them at debug level only.
this.logger.debug(
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
);
return { queued: true };
}
}

View File

@@ -1,199 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as sgMail from '@sendgrid/mail';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
export interface NotificationChannel {
send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>;
}
@Injectable()
export class EmailAdapter implements NotificationChannel {
private readonly logger = new Logger(EmailAdapter.name);
constructor(private readonly config: ConfigService) {
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
if (apiKey) {
sgMail.setApiKey(apiKey);
this.logger.log('SendGrid Email adapter initialized');
} else {
this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only');
}
}
async send(
recipient: string,
subject: string,
body: string,
context?: Record<string, unknown>,
): Promise<boolean> {
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
const fromEmail = this.config.get<string>('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com';
if (!apiKey) {
this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`);
return true;
}
try {
const msg: sgMail.MailDataRequired = {
to: recipient,
from: fromEmail,
subject,
text: body,
html: this.formatHtml(body, context),
};
await sgMail.send(msg);
this.logger.log(`Email sent successfully to ${recipient}`);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to send email to ${recipient}: ${message}`);
return false;
}
}
private formatHtml(body: string, context?: Record<string, unknown>): string {
const contextHtml = context
? `<div style="margin-top: 20px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<small>${JSON.stringify(context, null, 2)}</small>
</div>`
: '';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: white; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Ethio-Djibouti Railway</h2>
</div>
<div class="content">
${body.replace(/\n/g, '<br>')}
${contextHtml}
</div>
<div class="footer">
<p>© 2024 Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
}
@Injectable()
export class SmsAdapter implements NotificationChannel {
private readonly logger = new Logger(SmsAdapter.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const provider = this.config.get<string>('SMS_PROVIDER');
this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`);
}
async send(
recipient: string,
subject: string,
body: string,
_context?: Record<string, unknown>,
): Promise<boolean> {
const provider = this.config.get<string>('SMS_PROVIDER');
const apiKey = this.config.get<string>('SMS_API_KEY');
if (!provider || !apiKey) {
this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`);
return true;
}
try {
switch (provider.toLowerCase()) {
case 'twilio':
return await this.sendViaTwilio(recipient, body);
case 'africastalking':
return await this.sendViaAfricasTalking(recipient, body);
default:
this.logger.warn(`Unknown SMS provider: ${provider}`);
return false;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to send SMS to ${recipient}: ${message}`);
return false;
}
}
private async sendViaTwilio(to: string, body: string): Promise<boolean> {
const accountSid = this.config.get<string>('TWILIO_ACCOUNT_SID');
const authToken = this.config.get<string>('TWILIO_AUTH_TOKEN');
const fromNumber = this.config.get<string>('TWILIO_FROM_NUMBER');
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
const response = await firstValueFrom(
this.http.post(
url,
new URLSearchParams({
To: to,
From: fromNumber || '',
Body: body,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${auth}`,
},
},
),
);
return response.status === 201;
}
private async sendViaAfricasTalking(to: string, body: string): Promise<boolean> {
const apiKey = this.config.get<string>('SMS_API_KEY');
const username = this.config.get<string>('AFRICASTALKING_USERNAME');
const from = this.config.get<string>('AFRICASTALKING_FROM');
const url = 'https://api.africastalking.com/version1/messaging';
const response = await firstValueFrom(
this.http.post(
url,
new URLSearchParams({
username: username || '',
to,
message: body,
from: from || '',
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'apiKey': apiKey || '',
},
},
),
);
return response.status === 201;
}
}
@Injectable()
export class PushAdapter implements NotificationChannel {
private readonly logger = new Logger(PushAdapter.name);

View File

@@ -1,15 +1,23 @@
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard';
import { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
import { BulkMessagesDto, SingleMessageDto } from './dtos/sms.dto';
@ApiTags('Notifications')
@Controller('notifications')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class NotificationsController {
constructor(private service: NotificationsService) {}
constructor(
private service: NotificationsService,
private emailClient: EmailClientService,
private smsClient: SmsClientService,
) {}
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
@@ -29,6 +37,33 @@ export class NotificationsController {
return this.service.markAllRead(id);
}
@Post('send/email')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct email via the email microservice' })
@ApiBody({ type: SendEmail })
sendEmail(@Body() dto: SendEmail) {
return this.emailClient.sendEmail(dto);
}
@Post('send/sms')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
@ApiBody({ type: SingleMessageDto })
sendSms(@Body() dto: SingleMessageDto) {
return this.smsClient.sendSms(dto);
}
@Post('send/sms/bulk')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
@ApiBody({ type: BulkMessagesDto })
sendBulkSms(@Body() dto: BulkMessagesDto) {
return this.smsClient.sendBulkMessages(dto);
}
@Post('test')
// TODO(iam-authz): restrict to admin/staff via IAM PermissionGuard once role→permission mapping
// is confirmed. Currently protected by the class-level JwtGuard only.

View File

@@ -1,13 +1,44 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
import { PushAdapter } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
@Module({
imports: [HttpModule.register({ timeout: 10_000 })],
imports: [
// Required by IamGuard (injects HttpService) used in NotificationsController.
HttpModule.register({ timeout: 10_000 }),
ClientsModule.register([
{
name: 'EMAIL_SERVICE',
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
queue: process.env.EMAIL_QUEUE ?? 'email_queue',
queueOptions: { durable: true },
},
},
{
name: 'SMS_SERVICE',
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
queue: process.env.SMS_QUEUE ?? 'sms_queue',
queueOptions: { durable: true },
},
},
]),
],
controllers: [NotificationsController],
providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter],
exports: [NotificationsService],
providers: [
NotificationsService,
PushAdapter,
EmailClientService,
SmsClientService,
],
exports: [NotificationsService, EmailClientService, SmsClientService],
})
export class NotificationsModule {}

View File

@@ -1,10 +1,11 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, Logger } 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 { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
@@ -18,13 +19,13 @@ export class NotificationsService {
constructor(
private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private emailAdapter: EmailAdapter,
private smsAdapter: SmsAdapter,
private emailClient: EmailClientService,
private smsClient: SmsClientService,
private pushAdapter: PushAdapter,
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', this.emailAdapter as NotificationChannel],
['SMS', this.smsAdapter as 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],
]);
}
@@ -41,27 +42,38 @@ export class NotificationsService {
recipient: string,
context: Record<string, unknown>,
channels?: NotificationChannelType[],
): Promise<{ sent: boolean; channels: string[] }> {
): 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 { sent: false, channels: [] };
return { queued: false, channels: [] };
}
const { subject, body } = this.interpolate(template, context);
const targetChannels = channels || await this.getUserPreferredChannels(recipient);
const sentChannels: string[] = [];
// Always create in-app notification
if (targetChannels.includes('IN_APP')) {
await this.createInAppNotification(recipient, subject, body, context);
sentChannels.push('IN_APP');
// 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');
}
// Send via other channels
for (const channelType of targetChannels) {
if (channelType === 'IN_APP') continue;
@@ -77,46 +89,26 @@ export class NotificationsService {
continue;
}
const success = await adapter.send(recipientAddress, subject, body, context);
if (success) {
sentChannels.push(channelType);
const queued = await adapter.send(recipientAddress, subject, body, context);
if (queued) {
queuedChannels.push(channelType);
}
}
return { sent: sentChannels.length > 0, channels: sentChannels };
return { queued: queuedChannels.length > 0, channels: queuedChannels };
}
/**
* Legacy method for backward compatibility
* 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.
*/
async sendDirect(dto: SendNotificationDto) {
const notification = await this.prisma.notification.create({
data: {
passengerId: dto.passengerId,
title: dto.title,
body: dto.body,
category: dto.category as any,
deepLink: dto.deepLink,
metadata: dto.metadata,
},
});
const passenger = await this.prisma.passenger.findUnique({
where: { id: dto.passengerId },
});
if (passenger?.iamUserId) {
const contact = await this.resolveContactInfo(passenger.iamUserId);
if (contact.email) {
await this.emailAdapter.send(
contact.email,
this.sanitize(dto.title),
this.sanitize(dto.body),
);
}
}
return notification;
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(
@@ -157,16 +149,20 @@ export class NotificationsService {
template: { subject?: string | null; bodyTemplate: string },
context: Record<string, unknown>,
): { subject: string; body: string } {
const subject = template.subject || 'Notification';
let body = template.bodyTemplate;
return {
subject: this.applyVars(template.subject || 'Notification', context),
body: this.applyVars(template.bodyTemplate, context),
};
}
// Simple template interpolation: {{variable}}
/** 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');
body = body.replace(regex, String(value));
out = out.replace(regex, String(value));
}
return { subject, body };
return out;
}
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
@@ -251,27 +247,238 @@ export class NotificationsService {
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
const booking = payload.booking;
await this.send(
'booking.created',
payload.booking.passengerId,
booking.passengerId,
{
bookingRef: payload.booking.bookingRef,
bookingRef: booking.bookingRef,
amount: this.formatAmount(booking),
currency: booking.displayCurrency ?? 'ETB',
category: 'BOOKING',
deepLink: `edr://bookings/${payload.booking.bookingRef}`,
deepLink: `edr://bookings/${booking.bookingRef}`,
},
// For now, always notify the travelling passenger on every channel.
['IN_APP', 'EMAIL', 'SMS'],
);
}
/**
* 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) {
await this.send(
'payment.succeeded',
payload.booking.passengerId,
{
bookingRef: payload.booking.bookingRef,
category: 'PAYMENT',
deepLink: `edr://tickets/${payload.booking.bookingRef}`,
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.findUnique({ where: { bookingId } });
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const amount = this.formatAmount(booking ?? payload.booking);
const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB';
const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?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}` },
);
// 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);
await this.deliverSms(passengerId, text);
return;
}
// SMS — short pointer (no HTML/QR over SMS).
await this.deliverSms(
passengerId,
`EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
);
// 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>`;
}
@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);
}
}

View File

@@ -0,0 +1,68 @@
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
@Inject("SMS_SERVICE")
private smsClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() {
if (!this.enabled) return;
this.smsClient
.connect()
.then(() => {
this.logger.log("connected to SMS service");
})
.catch((err) => {
console.error("Error happened at SMS service", err);
});
}
async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
return { queued: false };
}
this.smsClient.emit("send-sms", {
to: dto.to,
text: dto.message,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
this.logger.log(
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
return { queued: true };
}
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
return { queued: false };
}
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
this.smsClient.emit("ozeking-bulk-sms", {
messages,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
this.logger.log(
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
);
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
return { queued: true };
}
}