mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -412,6 +412,17 @@ export class BookingsController {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
@ApiOperation({
|
||||
summary: 'Check if booking is in use',
|
||||
description: 'Returns list of modules/data that reference this booking'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
checkUsage(@Param('id') id: string) {
|
||||
return this.service.checkBookingUsage(id);
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@ApiOperation({
|
||||
summary: 'Get booking details by reference (no auth required)',
|
||||
@@ -423,17 +434,6 @@ export class BookingsController {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Update booking details',
|
||||
description: 'Updates booking information for admin/agent operations'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
update(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':bookingRef/modify')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -458,17 +458,17 @@ export class BookingsController {
|
||||
return this.service.delete(id);
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Check if booking is in use',
|
||||
description: 'Returns list of modules/data that reference this booking'
|
||||
summary: 'Update booking details',
|
||||
description: 'Updates booking information for admin/agent operations'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
|
||||
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
checkUsage(@Param('id') id: string) {
|
||||
return this.service.checkBookingUsage(id);
|
||||
update(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
|
||||
@Delete(':bookingRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -1002,31 +1002,47 @@ export class BookingsService {
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, ticket: true,
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
return {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
|
||||
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
|
||||
totalMinor: booking.totalMinor, currency: 'ETB',
|
||||
adultCount: booking.adultCount, childCount: booking.childCount,
|
||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
|
||||
contactEmail: booking.contactEmail,
|
||||
contactPhone: booking.contactPhone,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
number: booking.schedule.train.number,
|
||||
id: booking.schedule.id,
|
||||
trainNumber: booking.schedule.train.number,
|
||||
trainName: booking.schedule.train.name,
|
||||
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
|
||||
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
|
||||
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
|
||||
},
|
||||
passengers: booking.seats?.map((bs: any) => ({
|
||||
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
|
||||
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
|
||||
fullName: bs.passengerName,
|
||||
category: bs.passengerCategory,
|
||||
leg: bs.leg ?? 1,
|
||||
fareMinor: bs.fareMinor,
|
||||
verifaydaVerified: bs.verifaydaVerified,
|
||||
seat: {
|
||||
id: bs.seat.id,
|
||||
number: bs.seat.seatNumber,
|
||||
coach: bs.seat.coach.number,
|
||||
coachId: bs.seat.coach.id,
|
||||
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
|
||||
},
|
||||
})),
|
||||
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
|
||||
ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1053,6 +1069,7 @@ export class BookingsService {
|
||||
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
|
||||
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
||||
}
|
||||
|
||||
|
||||
@@ -205,9 +205,9 @@ export class GuestBookingService {
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
bookingType: 'ONE_WAY',
|
||||
userAgent: dto.deviceId,
|
||||
// contactEmail: firstPassenger.email, // Temporarily disabled until migration
|
||||
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
seats: {
|
||||
create: passengersData.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
@@ -399,6 +399,8 @@ export class GuestBookingService {
|
||||
returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map((p) => ({
|
||||
@@ -589,6 +591,8 @@ export class GuestBookingService {
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
leg2SeatClassId: leg2SeatClassId,
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
@@ -800,6 +804,8 @@ export class GuestBookingService {
|
||||
returnLeg2SeatClassId: retL2ClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
||||
|
||||
@@ -40,18 +40,5 @@ export class SendEmail {
|
||||
@IsOptional()
|
||||
context?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class SingleMessageDto {
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
sms: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class BulkMessagesDto {
|
||||
|
||||
@@ -28,12 +28,23 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmail) {
|
||||
if (!this.enabled) return {};
|
||||
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",
|
||||
});
|
||||
return {};
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,12 +3,13 @@ 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: [
|
||||
// Required by IamGuard (injects HttpService) used in NotificationsController.
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ClientsModule.register([
|
||||
{
|
||||
@@ -34,8 +35,6 @@ import { SmsClientService } from './sms-client.service';
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
EmailAdapter,
|
||||
SmsAdapter,
|
||||
PushAdapter,
|
||||
EmailClientService,
|
||||
SmsClientService,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
|
||||
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
@@ -20,8 +19,8 @@ export class NotificationsService {
|
||||
private pushAdapter: PushAdapter,
|
||||
) {
|
||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
|
||||
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
|
||||
['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],
|
||||
]);
|
||||
}
|
||||
@@ -38,27 +37,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;
|
||||
|
||||
@@ -74,44 +84,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 },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (passenger?.user) {
|
||||
await this.emailClient.sendEmail({
|
||||
to: passenger.user.email,
|
||||
subject: this.sanitize(dto.title),
|
||||
text: 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(
|
||||
@@ -154,22 +146,31 @@ 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[]> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
||||
OR: [
|
||||
{ id: recipient },
|
||||
{ email: recipient },
|
||||
{ phone: recipient },
|
||||
{ passenger: { id: recipient } },
|
||||
],
|
||||
},
|
||||
include: { preferences: true },
|
||||
});
|
||||
@@ -192,7 +193,12 @@ export class NotificationsService {
|
||||
): Promise<string | null> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
||||
OR: [
|
||||
{ id: recipient },
|
||||
{ email: recipient },
|
||||
{ phone: recipient },
|
||||
{ passenger: { id: recipient } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -211,12 +217,6 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(value: string): string {
|
||||
return value
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
|
||||
}
|
||||
|
||||
getForPassenger(passengerId: string) {
|
||||
return this.prisma.notification.findMany({
|
||||
where: { passengerId },
|
||||
@@ -239,27 +239,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);
|
||||
}
|
||||
}
|
||||
@@ -30,21 +30,39 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
async sendSms(dto: SingleMessageDto) {
|
||||
if (!this.enabled) return {};
|
||||
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", {
|
||||
...dto,
|
||||
to: dto.to,
|
||||
text: dto.message,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
return {};
|
||||
// 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) {
|
||||
if (!this.enabled) return {};
|
||||
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", {
|
||||
...dto,
|
||||
messages,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
return {};
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,9 @@ export class PaymentsService {
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.bookingRef,
|
||||
amountMinor: booking.totalMinor,
|
||||
// Send the REAL (major) price, not minor units. The payment API no longer divides by 100
|
||||
// (freight already passes the real price), so the providers charge this value as-is.
|
||||
amountMinor: booking.totalMinor / 100,
|
||||
currency: booking.currency,
|
||||
provider: method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
@@ -620,6 +622,12 @@ export class PaymentsService {
|
||||
failureMessage: event.failureMessage,
|
||||
});
|
||||
}
|
||||
const failedBooking = await this.prisma.booking.findUnique({
|
||||
where: { id: event.referenceId },
|
||||
});
|
||||
if (failedBooking) {
|
||||
this.eventEmitter.emit("payment.failed", { booking: failedBooking });
|
||||
}
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
@@ -634,11 +642,15 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
if (booking.totalMinor !== event.amountMinor) {
|
||||
// The event carries the REAL (major) price the provider charged (passenger now sends
|
||||
// booking.totalMinor/100 on initiate), so convert it back to minor units before comparing
|
||||
// with booking.totalMinor (which is in minor units).
|
||||
const eventAmountMinor = Math.round(event.amountMinor * 100);
|
||||
if (booking.totalMinor !== eventAmountMinor) {
|
||||
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
|
||||
// which is the alertable signal for an asserted-vs-paid amount divergence.
|
||||
this.logger.error(
|
||||
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`,
|
||||
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Event amount does not match booking total",
|
||||
|
||||
@@ -591,11 +591,12 @@ export class SearchService {
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
coachTypeCode: string;
|
||||
coachId: string;
|
||||
classes: Array<{ name: string; baseFareMinor: number }>;
|
||||
}>> {
|
||||
const coachTypeMap = new Map<
|
||||
string,
|
||||
{ coachType: any; classNames: Set<string> }
|
||||
{ coachType: any; classNames: Set<string>; coachId: string }
|
||||
>();
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
@@ -606,6 +607,7 @@ export class SearchService {
|
||||
coachTypeMap.set(coachType.id, {
|
||||
coachType,
|
||||
classNames: new Set(),
|
||||
coachId: assignment.coach.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -614,7 +616,7 @@ export class SearchService {
|
||||
}
|
||||
|
||||
const result = [];
|
||||
for (const [, { coachType, classNames }] of coachTypeMap) {
|
||||
for (const [, { coachType, classNames, coachId }] of coachTypeMap) {
|
||||
const classes = Array.from(classNames)
|
||||
.map((className) => {
|
||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||
@@ -628,6 +630,7 @@ export class SearchService {
|
||||
coachTypeId: coachType.id,
|
||||
coachTypeName: coachType.name,
|
||||
coachTypeCode: coachType.code,
|
||||
coachId,
|
||||
classes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,10 +96,20 @@ export class TicketsService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
paymentIntent: true,
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
const paymentStatus = booking.paymentIntent?.status ?? null;
|
||||
throw new BadRequestException(
|
||||
`Payment not completed. Please complete your payment before accessing the ticket. ` +
|
||||
`Booking status: ${booking.status}` +
|
||||
(paymentStatus ? `. Payment status: ${paymentStatus}` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
||||
const legSummary = this.buildLegSummary(booking);
|
||||
const qrData = JSON.stringify({
|
||||
@@ -284,7 +294,7 @@ export class TicketsService {
|
||||
if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
|
||||
throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
|
||||
}
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
|
||||
if (alreadyValidated) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
@@ -336,7 +346,7 @@ export class TicketsService {
|
||||
if (!validLegs.includes(resolvedLeg)) {
|
||||
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
|
||||
}
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||
if (logs.some(l => l.leg === resolvedLeg)) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
@@ -438,7 +448,7 @@ export class TicketsService {
|
||||
booking.bookingType === 'TRANSIT' ||
|
||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isMultiLeg && offlineLeg) {
|
||||
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||
if (existingLogs.some(l => l.leg === offlineLeg)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
|
||||
@@ -55,8 +55,9 @@ export class VerifaydaController {
|
||||
summary: 'Start a VeriFayda 2.0 verification session',
|
||||
description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to.
|
||||
|
||||
- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user; when \`saveToAccount\` is true their account is marked verified on success.
|
||||
- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified.
|
||||
- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user.
|
||||
- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender).
|
||||
- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT.
|
||||
- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`,
|
||||
})
|
||||
@ApiOkResponse({
|
||||
@@ -73,11 +74,9 @@ export class VerifaydaController {
|
||||
@Req() req: RequestWithOptionalUser,
|
||||
): Promise<{ authorizationUrl: string }> {
|
||||
const authorizationUrl = await this.service.startVerification({
|
||||
purpose: dto.purpose ?? 'PURCHASE',
|
||||
purpose: dto.purpose ?? 'VERIFY',
|
||||
platform: dto.platform ?? 'WEB',
|
||||
userId: req.user?.userId,
|
||||
bookingId: dto.bookingId,
|
||||
saveToAccount: dto.saveToAccount,
|
||||
});
|
||||
return { authorizationUrl };
|
||||
}
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class StartVerificationDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: ['LOGIN', 'PURCHASE'],
|
||||
default: 'PURCHASE',
|
||||
description: 'Reason for verification.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['LOGIN', 'PURCHASE'])
|
||||
purpose?: 'LOGIN' | 'PURCHASE';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['LOGIN', 'VERIFY'],
|
||||
default: 'VERIFY',
|
||||
description:
|
||||
'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.',
|
||||
'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'When true and the user is logged in, copy faydaVerified=true / faydaSub onto their User record after verification.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
saveToAccount?: boolean;
|
||||
@IsIn(['LOGIN', 'VERIFY'])
|
||||
purpose?: 'LOGIN' | 'VERIFY';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['WEB', 'MOBILE'],
|
||||
@@ -39,8 +24,8 @@ export class StartVerificationDto {
|
||||
}
|
||||
|
||||
export class CompleteVerificationResultDto {
|
||||
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
|
||||
purpose: 'LOGIN' | 'PURCHASE';
|
||||
@ApiProperty({ enum: ['LOGIN', 'VERIFY'] })
|
||||
purpose: 'LOGIN' | 'VERIFY';
|
||||
|
||||
@ApiProperty() verified: boolean;
|
||||
|
||||
@@ -58,10 +43,22 @@ export class CompleteVerificationResultDto {
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Verified full name from Fayda (PURCHASE flow).',
|
||||
})
|
||||
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
|
||||
fullName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' })
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' })
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).',
|
||||
})
|
||||
birthdate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
|
||||
gender?: string;
|
||||
}
|
||||
|
||||
export class VerifaydaCallbackDto {
|
||||
|
||||
@@ -96,13 +96,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||
|
||||
const url = await service.startVerification({
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
userId: 'user-1',
|
||||
saveToAccount: true,
|
||||
});
|
||||
|
||||
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
||||
expect(created.purpose).toBe('PURCHASE');
|
||||
expect(created.purpose).toBe('VERIFY');
|
||||
expect(created.platform).toBe('WEB');
|
||||
expect(typeof created.state).toBe('string');
|
||||
expect(typeof created.codeVerifier).toBe('string');
|
||||
@@ -139,7 +138,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
jwt,
|
||||
);
|
||||
await expect(
|
||||
disabledService.startVerification({ purpose: 'PURCHASE' }),
|
||||
disabledService.startVerification({ purpose: 'VERIFY' }),
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
});
|
||||
@@ -150,14 +149,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
id: 'session-1',
|
||||
state: 'state-abc',
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
errorCode: null,
|
||||
errorDescription: null,
|
||||
userId: null,
|
||||
bookingId: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
...overrides,
|
||||
};
|
||||
@@ -209,18 +206,16 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeVerification — PURCHASE', () => {
|
||||
describe('completeVerification — VERIFY', () => {
|
||||
function pendingSession(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'session-1',
|
||||
state: 'state-abc',
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
userId: null,
|
||||
bookingId: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
...overrides,
|
||||
};
|
||||
@@ -238,19 +233,23 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||
}
|
||||
|
||||
it('stamps the booking seats and returns { verified, fullName }', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ bookingId: 'booking-1' }),
|
||||
);
|
||||
it('returns the verified identity attributes and writes no domain rows', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
mockFetchSequence(
|
||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||
{
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
text: async () =>
|
||||
JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
|
||||
JSON.stringify({
|
||||
sub: 'fayda-sub-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phone_number: '+251911000000',
|
||||
birthdate: '1990-05-01',
|
||||
gender: 'Male',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -260,65 +259,17 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
fullName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneNumber: '+251911000000',
|
||||
birthdate: '1990-05-01',
|
||||
gender: 'Male',
|
||||
});
|
||||
expect(result.token).toBeUndefined();
|
||||
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
|
||||
where: { bookingId: 'booking-1' },
|
||||
data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('saves to the User account when saveToAccount=true and no conflict', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ userId: 'user-1', saveToAccount: true }),
|
||||
);
|
||||
prisma.user.findFirst.mockResolvedValue(null);
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||
|
||||
mockFetchSequence(
|
||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||
{
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
text: async () =>
|
||||
JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.completeVerification({
|
||||
code: 'authcode',
|
||||
state: 'state-abc',
|
||||
});
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ userId: 'user-1', saveToAccount: true }),
|
||||
);
|
||||
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
|
||||
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
mockFetchSequence(
|
||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||
{
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
text: async () =>
|
||||
JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }),
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
expect(result.user).toBeUndefined();
|
||||
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -353,11 +304,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
});
|
||||
|
||||
it('falls back to localized name (name#en) when name is missing', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ bookingId: 'booking-2' }),
|
||||
);
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
mockFetchSequence(
|
||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||
@@ -377,9 +325,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
state: 'state-abc',
|
||||
});
|
||||
expect(result.fullName).toBe('English Name');
|
||||
expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
|
||||
'English Name',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,10 +336,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'LOGIN',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
userId: null,
|
||||
bookingId: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -49,8 +49,6 @@ export interface StartVerificationInput {
|
||||
purpose: VerifaydaPurpose;
|
||||
platform?: FaydaPlatform;
|
||||
userId?: string;
|
||||
bookingId?: string;
|
||||
saveToAccount?: boolean;
|
||||
}
|
||||
|
||||
export interface FaydaUserSummary {
|
||||
@@ -63,7 +61,8 @@ export interface FaydaUserSummary {
|
||||
|
||||
/**
|
||||
* Result of completing a verification. `verified` is always true on success.
|
||||
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
|
||||
* LOGIN additionally returns a JWT + user; VERIFY returns the verified identity
|
||||
* attributes (name, email, phone, dob, gender) for the caller to consume.
|
||||
*/
|
||||
export interface CompleteVerificationResult {
|
||||
purpose: VerifaydaPurpose;
|
||||
@@ -71,6 +70,10 @@ export interface CompleteVerificationResult {
|
||||
token?: string;
|
||||
user?: FaydaUserSummary;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -143,15 +146,13 @@ export class VerifaydaService {
|
||||
codeVerifier,
|
||||
purpose: input.purpose,
|
||||
platform: input.platform ?? 'WEB',
|
||||
saveToAccount: input.saveToAccount ?? false,
|
||||
userId: input.userId ?? null,
|
||||
bookingId: input.bookingId ?? null,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
|
||||
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`,
|
||||
);
|
||||
|
||||
return this.buildAuthorizationUrl({ state, codeChallenge });
|
||||
@@ -215,17 +216,22 @@ export class VerifaydaService {
|
||||
}
|
||||
|
||||
let result: CompleteVerificationResult;
|
||||
if (session.purpose === 'PURCHASE') {
|
||||
await this.handlePurchaseSuccess(session, normalized);
|
||||
result = {
|
||||
purpose: 'PURCHASE',
|
||||
verified: true,
|
||||
fullName: normalized.fullName,
|
||||
};
|
||||
} else {
|
||||
if (session.purpose === 'LOGIN') {
|
||||
const { userId } = await this.handleLoginSuccess(normalized);
|
||||
const login = await this.issueLoginToken(userId);
|
||||
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||
} else {
|
||||
// VERIFY — prove identity and hand the verified attributes back to the
|
||||
// caller. No domain writes; the session row tracks status as usual.
|
||||
result = {
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
fullName: normalized.fullName,
|
||||
email: normalized.email,
|
||||
phoneNumber: normalized.phoneNumber,
|
||||
birthdate: normalized.birthdate,
|
||||
gender: normalized.gender,
|
||||
};
|
||||
}
|
||||
|
||||
await this.prisma.faydaVerificationSession.update({
|
||||
@@ -422,49 +428,6 @@ export class VerifaydaService {
|
||||
};
|
||||
}
|
||||
|
||||
private async handlePurchaseSuccess(
|
||||
session: {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
bookingId: string | null;
|
||||
saveToAccount: boolean;
|
||||
},
|
||||
normalized: NormalizedFaydaUserInfo,
|
||||
): Promise<void> {
|
||||
if (session.bookingId) {
|
||||
await this.prisma.bookingSeat.updateMany({
|
||||
where: { bookingId: session.bookingId },
|
||||
data: {
|
||||
faydaVerifiedAt: new Date(),
|
||||
faydaSub: normalized.sub,
|
||||
faydaVerifiedName: normalized.fullName ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (session.userId && session.saveToAccount) {
|
||||
const conflict = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
faydaSub: normalized.sub,
|
||||
NOT: { id: session.userId },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (conflict) {
|
||||
throw new FaydaIdentityConflictException();
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: session.userId },
|
||||
data: {
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: new Date(),
|
||||
faydaSub: normalized.sub,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
|
||||
* JWT via {@link issueLoginToken}). Resolution order:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
|
||||
export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
|
||||
|
||||
export interface FaydaTokenResponse {
|
||||
access_token: string;
|
||||
|
||||
Reference in New Issue
Block a user