This commit is contained in:
Stephanos A
2026-06-23 10:13:36 +03:00
319 changed files with 24404 additions and 4370 deletions

View File

@@ -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')

View File

@@ -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' };
}

View File

@@ -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)),

View File

@@ -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;
}

View File

@@ -34,7 +34,7 @@ export class SingleMessageDto {
})
@IsString()
@IsNotEmpty()
sms: string;
message: string;
}
export class BulkMessagesDto {

View File

@@ -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 };
}
}

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

@@ -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,

View File

@@ -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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[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);
}
}

View File

@@ -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 };
}
}

View File

@@ -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",

View File

@@ -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,
});
}

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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 {

View File

@@ -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,
};

View File

@@ -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:

View File

@@ -1,4 +1,4 @@
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
export interface FaydaTokenResponse {
access_token: string;