IAM, package, luggage, app health, rate limit, and more

This commit is contained in:
Stephanos A
2026-06-24 14:02:51 +03:00
parent e10b013b62
commit 86760933e8
63 changed files with 3475 additions and 280 deletions

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Patch, Delete, Param, Request, Query, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PassengerAuthService } from './passenger-auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
@@ -7,6 +8,7 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Auth')
@Controller('auth')
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
export class AuthController {
constructor(private passengerAuthService: PassengerAuthService) {}
@@ -66,4 +68,54 @@ export class AuthController {
}
// TODO: admin user management endpoints — implement when admin module is ready
@Get('users')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all users (admin)' })
listUsers(
@Query('search') search?: string,
@Query('role') role?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.passengerAuthService.listUsers({
search, role, status,
page: page ? +page : 1,
pageSize: pageSize ? +pageSize : 20,
});
}
@Post('users')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create user (admin)' })
createUser(@Body() body: any) {
return this.passengerAuthService.createUser(body);
}
@Patch('users/:id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update user (admin)' })
updateUser(@Param('id') id: string, @Body() body: any) {
return this.passengerAuthService.updateUser(id, body);
}
@Delete('users/:id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete user (admin)' })
deleteUser(@Param('id') id: string) {
return this.passengerAuthService.deleteUser(id);
}
@Post('users/:id/reset-password')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reset user password (admin)' })
resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) {
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
}
}

View File

@@ -193,6 +193,209 @@ export class PassengerAuthService {
};
}
async listUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
const offset = (page - 1) * pageSize;
const params: any[] = [];
const conditions: string[] = [];
if (filters.search) {
params.push(`%${filters.search}%`);
conditions.push(`(u.email ILIKE $${params.length} OR (u.name->>'en') ILIKE $${params.length})`);
}
if (filters.role) {
params.push(`%${filters.role}%`);
conditions.push(`r.key ILIKE $${params.length}`);
}
if (filters.status) {
const active = filters.status === 'ACTIVE';
params.push(active);
conditions.push(`u.is_active = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const baseQuery = `
FROM iam.users u
LEFT JOIN iam.user_roles ur ON ur.user_id = u.id
LEFT JOIN iam.roles r ON r.id = ur.role_id
${where}
`;
const countParams = [...params];
const [rows, countRows] = await Promise.all([
this.dataSource.query(
`SELECT DISTINCT u.id, u.email, u.name, u.phone_number, u.is_active, u.status, u.created_at,
r.key as role_key, r.name as role_name
${baseQuery}
ORDER BY u.created_at DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, pageSize, offset],
),
this.dataSource.query(
`SELECT COUNT(DISTINCT u.id) as count ${baseQuery}`,
countParams,
),
]);
const items = rows.map((u: any) => ({
id: u.id,
email: u.email,
fullName: u.name?.en ?? u.name?.am ?? '',
role: u.role_key ?? '',
status: u.is_active ? 'ACTIVE' : 'INACTIVE',
lastLogin: u.metadata?.lastLogin ?? null,
createdAt: u.created_at,
}));
return { items, total: parseInt(countRows[0]?.count ?? '0'), page, pageSize };
}
async createUser(data: { email: string; fullName: string; role: string; password: string; status?: string }) {
const existing = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`,
[data.email],
);
if (existing.length) throw new ConflictException('Email already registered');
// Derive username from email local-part; ensure uniqueness by appending a short suffix if taken
const baseUsername = data.email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, '');
const taken = await this.dataSource.query<{ username: string }[]>(
`SELECT username FROM iam.users WHERE username LIKE $1 LIMIT 10`,
[`${baseUsername}%`],
);
const takenSet = new Set(taken.map((r) => r.username));
let username = baseUsername;
let suffix = 1;
while (takenSet.has(username)) {
username = `${baseUsername}${suffix++}`;
}
// Hash with argon2 — same algorithm the IAM login uses (verifyPassword in auth.service.js)
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
const passwordHash = await hashPassword(data.password);
await this.dataSource.query(
`INSERT INTO iam.users (email, username, name, user_type, status, is_active)
VALUES ($1, $2, $3::jsonb, 'employee', $4, $5)`,
[
data.email,
username,
JSON.stringify({ en: data.fullName, am: data.fullName }),
data.status === 'INACTIVE' ? 'pending' : 'accepted',
data.status !== 'INACTIVE',
],
);
// Insert credential with correct column `password` and is_active = true
// so the IAM login SQL (find-user-for-login.sql) can find and verify it
const newUser = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, [data.email],
);
if (newUser.length) {
await this.dataSource.query(
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
[newUser[0].id],
);
await this.dataSource.query(
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
[newUser[0].id, passwordHash],
);
}
// Assign the selected role in iam.user_roles
const rows = await this.dataSource.query(
`SELECT id, email, name, is_active, created_at FROM iam.users WHERE email = $1 LIMIT 1`,
[data.email],
);
const u = rows[0];
if (data.role && u) {
try {
const roleRows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`,
[data.role],
);
if (roleRows.length) {
await this.dataSource.query(
`INSERT INTO iam.user_roles (user_id, role_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[u.id, roleRows[0].id],
);
}
} catch {
// non-fatal — role assignment failure should not block user creation
}
}
return {
id: u.id, email: u.email,
fullName: data.fullName, role: data.role,
status: u.is_active ? 'ACTIVE' : 'INACTIVE',
createdAt: u.created_at,
};
}
async updateUser(id: string, data: { fullName?: string; role?: string; status?: string }) {
const rows = await this.dataSource.query(
`SELECT id, name, is_active FROM iam.users WHERE id = $1 LIMIT 1`,
[id],
);
if (!rows.length) throw new ConflictException('User not found');
const existing = rows[0];
const name = data.fullName ? { en: data.fullName, am: data.fullName } : existing.name;
const isActive = data.status ? data.status === 'ACTIVE' : existing.is_active;
await this.dataSource.query(
`UPDATE iam.users SET name = $1::jsonb, is_active = $2, updated_at = NOW() WHERE id = $3`,
[JSON.stringify(name), isActive, id],
);
// Update role: remove existing user_roles then assign the new one
if (data.role) {
try {
const roleRows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`,
[data.role],
);
if (roleRows.length) {
await this.dataSource.query(`DELETE FROM iam.user_roles WHERE user_id = $1`, [id]);
await this.dataSource.query(
`INSERT INTO iam.user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[id, roleRows[0].id],
);
}
} catch {
// non-fatal
}
}
return { id, fullName: (name as any)?.en, role: data.role, status: isActive ? 'ACTIVE' : 'INACTIVE' };
}
async deleteUser(id: string) {
await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [id]);
return { success: true };
}
async resetUserPassword(id: string, tempPassword: string) {
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
const passwordHash = await hashPassword(tempPassword);
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
await this.dataSource.query(
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
[id],
);
// Insert new active credential
await this.dataSource.query(
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
[id, passwordHash],
);
return { success: true, message: 'Password reset successfully' };
}
private async compensateIamSignup(email: string): Promise<void> {
try {
const rows = await this.dataSource.query<{ id: string }[]>(

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
@@ -8,6 +9,7 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Booking')
@Controller('bookings')
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class BookingsController {
constructor(
private service: BookingsService,

View File

@@ -0,0 +1,80 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ExcessBaggageService } from './excess-baggage.service';
import {
LogExcessBaggageDto,
WaiveChargeDto,
InitiateExcessPaymentDto,
} from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
@ApiTags('Excess Baggage')
@Controller('agents/excess-baggage')
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth')
export class ExcessBaggageAgentController {
constructor(private service: ExcessBaggageService) {}
@Post()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Body() dto: LogExcessBaggageDto) {
return this.service.logCharge(dto);
}
@Get()
@ApiOperation({ summary: 'List all excess baggage charges (admin/supervisor)' })
getAll(
@Query('status') status?: string,
@Query('bookingRef') bookingRef?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getAll({
status,
bookingRef,
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
getCharge(@Param('id') id: string) {
return this.service.getCharge(id);
}
@Post(':id/resend')
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);
}
@Patch(':id/waive')
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
@ApiTags('Excess Baggage')
@Controller('excess-baggage')
export class ExcessBaggagePublicController {
constructor(private service: ExcessBaggageService) {}
@Get('pay/:token')
@ApiOperation({ summary: 'Retrieve charge details by payment token (public)' })
getByToken(@Param('token') token: string) {
return this.service.getByToken(token);
}
@Post('pay/:token/initiate')
@ApiOperation({ summary: 'Passenger initiates payment for excess baggage charge' })
initiatePayment(
@Param('token') token: string,
@Body() dto: InitiateExcessPaymentDto,
) {
return this.service.initiatePayment(token, dto);
}
}

View File

@@ -0,0 +1,22 @@
import { IsString, IsInt, IsOptional, IsPositive } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class LogExcessBaggageDto {
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
@ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string;
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
@IsInt() @IsPositive() excessWeightKg: number;
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })
@IsOptional() collectCash?: boolean;
}
export class WaiveChargeDto {
@ApiProperty() @IsString() waivedBy: string;
@ApiPropertyOptional() @IsOptional() @IsString() waivedReason?: string;
}
export class InitiateExcessPaymentDto {
@ApiProperty({ enum: ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'DMONEY', 'CARD'] })
@IsString() method: string;
@ApiPropertyOptional({ enum: ['web', 'mobile'] }) @IsOptional() platform?: string;
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ExcessBaggageService } from './excess-baggage.service';
import {
ExcessBaggageAgentController,
ExcessBaggagePublicController,
} from './excess-baggage.controller';
import { PaymentsModule } from '../payments/payments.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [HttpModule, PaymentsModule, NotificationsModule],
controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController],
providers: [ExcessBaggageService],
exports: [ExcessBaggageService],
})
export class ExcessBaggageModule {}

View File

@@ -0,0 +1,252 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { NotificationsService } from '../notifications/notifications.service';
import {
LogExcessBaggageDto,
WaiveChargeDto,
InitiateExcessPaymentDto,
} from './excess-baggage.dto';
import {
PaymentService as PaymentServiceEnum,
PaymentReferenceType,
ProviderMethod,
ProviderPaymentStatus,
} from '@edr/types';
import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes
@Injectable()
export class ExcessBaggageService {
private readonly logger = new Logger(ExcessBaggageService.name);
constructor(
private prisma: PrismaService,
private paymentClient: PaymentClientService,
private notifications: NotificationsService,
) {}
async logCharge(dto: LogExcessBaggageDto) {
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },
include: {
seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
}
// Resolve fee per kg from BaggageAllowance via seat class
const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId;
let feePerKgMinor = 5000; // 50 ETB default fallback (in minor)
if (coachTypeId) {
const seatClass = await this.prisma.seatClass.findFirst({
where: { coachTypeId },
});
if (seatClass) {
const allowance = await this.prisma.baggageAllowance.findFirst({
where: { seatClassId: seatClass.id },
});
if (allowance) feePerKgMinor = allowance.excessFeePerKg;
}
}
const totalMinor = feePerKgMinor * dto.excessWeightKg;
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING';
const paidAt = dto.collectCash ? new Date() : null;
const charge = await this.prisma.excessBaggageCharge.create({
data: {
bookingId: dto.bookingId,
agentId: dto.agentId,
excessWeightKg: dto.excessWeightKg,
feePerKgMinor,
totalMinor,
status,
expiresAt,
paidAt,
contactPhone,
contactEmail,
},
});
if (!dto.collectCash) {
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
}
return charge;
}
private async sendPaymentLink(
charge: any,
booking: any,
phone: string | null,
email: string | null,
) {
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const payUrl = `${portalUrl}/excess-baggage/pay/${charge.paymentToken}`;
const amountStr = (charge.totalMinor / 100).toFixed(2);
const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`;
const recipient = phone ?? email ?? booking.passengerId;
try {
await this.notifications['deliverSms'](recipient, msg);
} catch (err) {
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
}
if (email) {
try {
await this.notifications['deliverEmail'](
recipient,
`EDR — Excess baggage payment required (${booking.bookingRef})`,
msg,
);
} catch (err) {
this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`);
}
}
}
async getCharge(id: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { id },
include: { booking: { select: { bookingRef: true, status: true } } },
});
if (!charge) throw new NotFoundException('Charge not found');
return charge;
}
async getByToken(token: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { paymentToken: token },
include: { booking: { select: { bookingRef: true, scheduleId: true } } },
});
if (!charge) throw new NotFoundException('Payment link not found');
if (charge.status === 'EXPIRED' || new Date() > charge.expiresAt) {
if (charge.status === 'PENDING') {
await this.prisma.excessBaggageCharge.update({
where: { id: charge.id },
data: { status: 'EXPIRED' },
});
}
throw new BadRequestException('This payment link has expired');
}
if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') {
throw new BadRequestException('This charge has already been paid');
}
if (charge.status === 'WAIVED') {
throw new BadRequestException('This charge has been waived');
}
return charge;
}
async initiatePayment(token: string, dto: InitiateExcessPaymentDto) {
const charge = await this.getByToken(token);
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`;
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType,
referenceId: charge.id,
orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`,
amountMinor: charge.totalMinor / 100,
currency: charge.currency,
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform as any,
returnUrl,
failureUrl: returnUrl,
});
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markPaid(charge.id, snapshot.providerTxnId);
}
return {
chargeId: charge.id,
status: snapshot.status,
clientAction: snapshot.clientAction,
merchantOrderId: snapshot.merchantOrderId,
};
}
async markPaid(chargeId: string, providerTxnId?: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') return charge;
return this.prisma.excessBaggageCharge.update({
where: { id: chargeId },
data: { status: 'PAID', paidAt: new Date() },
});
}
async waiveCharge(id: string, dto: WaiveChargeDto) {
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) {
throw new BadRequestException('Cannot waive a charge that has already been paid');
}
return this.prisma.excessBaggageCharge.update({
where: { id },
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
});
}
async resendLink(id: string) {
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { id },
include: { booking: { select: { bookingRef: true, passengerId: true } } },
});
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status !== 'PENDING') {
throw new BadRequestException('Can only resend link for PENDING charges');
}
// Extend expiry by 30 minutes from now
const updatedCharge = await this.prisma.excessBaggageCharge.update({
where: { id },
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
});
await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail);
return { sent: true };
}
async getAll(filters: {
status?: string;
bookingRef?: string;
page?: number;
pageSize?: number;
}) {
const { status, bookingRef, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (status) where.status = status;
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
const [items, total] = await Promise.all([
this.prisma.excessBaggageCharge.findMany({
where,
include: { booking: { select: { bookingRef: true, status: true } } },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.excessBaggageCharge.count({ where }),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -0,0 +1,59 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PrismaService } from '../../common/prisma.service';
@ApiTags('Health')
@Controller('health')
@SkipThrottle()
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@IsPublic()
@ApiOperation({ summary: 'Liveness probe' })
liveness() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
@Get('ready')
@IsPublic()
@ApiOperation({ summary: 'Readiness probe — checks database connectivity' })
async readiness() {
const start = Date.now();
try {
await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'ok',
timestamp: new Date().toISOString(),
checks: { database: { status: 'ok', latencyMs: Date.now() - start } },
};
} catch (err) {
return {
status: 'error',
timestamp: new Date().toISOString(),
checks: {
database: {
status: 'error',
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : 'Unknown error',
},
},
};
}
}
@Get('info')
@IsPublic()
@ApiOperation({ summary: 'App info — version, environment, uptime' })
info() {
return {
name: 'edr-passenger-api',
version: process.env.npm_package_version ?? '1.0.0',
environment: process.env.NODE_ENV ?? 'development',
uptimeSeconds: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -439,6 +439,126 @@ export class NotificationsService {
</html>`;
}
async sendBoardingPassNotification(params: {
passengerId: string | null;
contactEmail: string | null;
contactPhone: string | null;
bookingRef: string;
leg: string | null;
booking: any;
ticket: any;
}): Promise<void> {
const { passengerId, contactEmail, contactPhone, bookingRef, leg, booking, ticket } = params;
// Resolve contact — prefer IAM user record, fall back to booking contact fields
let email: string | null = contactEmail ?? null;
let phone: string | null = contactPhone ?? null;
if (passengerId) {
const resolved = await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null);
const resolvedPhone = await this.getRecipientAddress(passengerId, 'SMS').catch(() => null);
if (resolved) email = resolved;
if (resolvedPhone) phone = resolvedPhone;
}
const s = booking.schedule ?? {};
const fmt = (d: any) =>
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
const origin = s.originStation?.name ?? '';
const dest = s.destinationStation?.name ?? '';
const train = s.train?.name ?? s.train?.number ?? '';
const dep = fmt(s.departureAt);
const arr = fmt(s.arrivalAt);
const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({
name: bs.passengerName ?? '',
coach: bs.seat?.coach?.number ?? '-',
seat: bs.seat?.seatNumber ?? '-',
cls: bs.seat?.coach?.coachType?.name ?? '-',
}));
const seatLines = seats.map(s => ` ${s.name} — Coach ${s.coach}, Seat ${s.seat} (${s.cls})`).join('\n');
const smsText =
`EDR Boarding Pass${legLabel}\n` +
`Ref: ${bookingRef}\n` +
`${origin}${dest}\n` +
`Train: ${train} | Dep: ${dep}\n` +
(seatLines ? `${seatLines}\n` : '') +
`Barcode: ${ticket.barcodePayload}`;
if (phone) {
await this.smsClient.sendSms({ to: phone, message: smsText }).catch((e) =>
this.logger.error(`Boarding pass SMS failed for ${bookingRef}: ${e?.message}`),
);
}
if (email) {
const seatRows = seats
.map(
(s) =>
`<tr>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.name}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.coach}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.seat}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${s.cls}</td>
</tr>`,
)
.join('');
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;color:#333;background:#f4f4f4;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#0066cc;color:#fff;padding:24px;text-align:center;">
<h2 style="margin:0;">Ethio-Djibouti Railway</h2>
<p style="margin:8px 0 0;">Boarding Pass${legLabel}</p>
</div>
<div style="padding:24px;">
<p>Booking reference: <strong>${bookingRef}</strong></p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr><td style="padding:8px 0;color:#666;">From</td><td style="text-align:right;"><strong>${origin}</strong></td></tr>
<tr><td style="padding:8px 0;color:#666;">To</td><td style="text-align:right;"><strong>${dest}</strong></td></tr>
<tr><td style="padding:8px 0;color:#666;">Train</td><td style="text-align:right;">${train}</td></tr>
<tr><td style="padding:8px 0;color:#666;">Departs</td><td style="text-align:right;">${dep}</td></tr>
<tr><td style="padding:8px 0;color:#666;">Arrives</td><td style="text-align:right;">${arr}</td></tr>
</table>
<h3 style="margin:16px 0 8px;">Passengers</h3>
<table style="width:100%;border-collapse:collapse;">
<tr style="color:#666;text-align:left;">
<th style="padding:8px;border-bottom:2px solid #eee;">Name</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Coach</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Seat</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Class</th>
</tr>
${seatRows}
</table>
<div style="text-align:center;margin:24px 0;">
<p style="color:#666;margin:0 0 8px;">QR code for gate scanning</p>
<img src="${ticket.qrPayload}" alt="Boarding pass QR" width="180" height="180"
style="border:1px solid #eee;padding:8px;background:#fff;" />
<p style="color:#666;font-size:12px;margin:8px 0 0;">Barcode: <strong>${ticket.barcodePayload}</strong></p>
</div>
</div>
<div style="text-align:center;padding:20px;color:#999;font-size:12px;">
<p style="margin:0;">© Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>`;
const textFallback =
`EDR Boarding Pass${legLabel}\nRef: ${bookingRef}\n${origin}${dest}\n` +
`Train: ${train} | Departs: ${dep} | Arrives: ${arr}\n${seatLines}\n` +
`Barcode: ${ticket.barcodePayload}`;
await this.emailClient
.sendEmail({ to: email, subject: `EDR Boarding Pass — ${bookingRef}${legLabel}`, text: textFallback, html })
.catch((e) => this.logger.error(`Boarding pass email failed for ${bookingRef}: ${e?.message}`));
}
}
@OnEvent('payment.failed')
async onPaymentFailed(payload: any) {
const booking = payload.booking;

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Patch, UseGuards, Request, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto } from './packages.dto';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@@ -52,6 +52,14 @@ export class PackagesController {
return this.service.create(dto);
}
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
return this.service.update(id, dto);
}
@Patch(':id/activate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@@ -60,6 +68,30 @@ export class PackagesController {
return this.service.activate(id);
}
@Post(':id/tiers')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
return this.service.addTier(id, dto);
}
@Patch('tiers/:tierId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
return this.service.updateTier(tierId, dto);
}
@Delete('tiers/:tierId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete price tier (admin)' })
deleteTier(@Param('tierId') tierId: string) {
return this.service.deleteTier(tierId);
}
@Post('book')
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -16,6 +16,13 @@ export class CreatePriceTierDto {
@IsInt() @Min(0) availableSeats: number;
}
export class UpdatePriceTierDto {
@ApiPropertyOptional() @IsOptional() @IsString() seatType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() label?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number;
@ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) availableSeats?: number;
}
export class CreatePackageDto {
@ApiProperty({ example: 'KULUBBI-2025' })
@IsString() code: string;

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto } from './packages.dto';
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto';
import { Currency } from '@prisma/client';
function generateRef(): string {
@@ -70,6 +70,53 @@ export class PackagesService {
});
}
async update(id: string, dto: Partial<CreatePackageDto>) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({
where: { id },
data: {
...(dto.code && { code: dto.code }),
...(dto.name && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.outboundScheduleId && { outboundScheduleId: dto.outboundScheduleId }),
...(dto.returnScheduleId && { returnScheduleId: dto.returnScheduleId }),
...(dto.originStationId && { originStationId: dto.originStationId }),
...(dto.destinationStationId && { destinationStationId: dto.destinationStationId }),
...(dto.boardingTime && { boardingTime: new Date(dto.boardingTime) }),
...(dto.departureTime && { departureTime: new Date(dto.departureTime) }),
...(dto.arrivalTime && { arrivalTime: new Date(dto.arrivalTime) }),
...(dto.totalCapacity && { totalCapacity: dto.totalCapacity }),
...(dto.coachConfiguration !== undefined && { coachConfiguration: dto.coachConfiguration }),
...(dto.includedServices && { includedServices: dto.includedServices }),
...(dto.busTransferIncluded !== undefined && { busTransferIncluded: dto.busTransferIncluded }),
...(dto.busTransferRoute !== undefined && { busTransferRoute: dto.busTransferRoute }),
...(dto.validFrom && { validFrom: new Date(dto.validFrom) }),
...(dto.validUntil && { validUntil: new Date(dto.validUntil) }),
},
include: { priceTiers: true },
});
}
async addTier(packageId: string, dto: CreatePriceTierDto) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.packagePriceTier.create({ data: { ...dto, packageId } });
}
async updateTier(tierId: string, dto: UpdatePriceTierDto) {
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } });
if (!tier) throw new NotFoundException('Price tier not found');
return this.prisma.packagePriceTier.update({ where: { id: tierId }, data: dto });
}
async deleteTier(tierId: string) {
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } });
if (!tier) throw new NotFoundException('Price tier not found');
if (tier.bookedSeats > 0) throw new BadRequestException('Cannot delete a tier that has bookings');
return this.prisma.packagePriceTier.delete({ where: { id: tierId } });
}
async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard';
@@ -9,6 +10,7 @@ import { PrismaService } from '../../common/prisma.service';
@ApiTags('Passengers')
@Controller('passengers')
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class PassengersController {
constructor(
private service: PassengersService,

View File

@@ -7,6 +7,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { SkipThrottle } from "@nestjs/throttler";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentsService } from "./payments.service";
@@ -20,6 +21,7 @@ import { PaymentsService } from "./payments.service";
@ApiTags("Internal Payments")
@UseGuards(ServiceAuthGuard)
@Controller("internal/payments")
@SkipThrottle()
export class InternalPaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}

View File

@@ -17,6 +17,7 @@ import {
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { SkipThrottle, Throttle } from "@nestjs/throttler";
import { Response } from "express";
import { PaymentsService } from "./payments.service";
import {
@@ -33,6 +34,7 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Payment")
@Controller("payments")
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class PaymentsController {
constructor(private service: PaymentsService) {}

View File

@@ -61,5 +61,6 @@ function rabbitMQImport(): DynamicModule[] {
PaymentEventsConsumer,
ServiceAuthGuard,
],
exports: [PaymentClientService],
})
export class PaymentsModule {}

View File

@@ -6,7 +6,7 @@ import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule],
imports: [SegmentsModule, HttpModule, SystemConfigModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],

View File

@@ -171,9 +171,26 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list');
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
const [holdMinutes, cutoffHours] = await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
]);
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { departureAt: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
const cutoffMs = cutoffHours * 60 * 60 * 1000;
if (msUntilDeparture <= cutoffMs) {
throw new BadRequestException(
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
);
}
const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({
where: { id: { in: seatIds } },

View File

@@ -3,10 +3,12 @@ import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
} as const;
const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
};
@Injectable()

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
@@ -16,6 +17,7 @@ interface OfflineValidation {
export class TicketsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@@ -308,6 +310,7 @@ export class TicketsService {
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
@@ -325,6 +328,7 @@ export class TicketsService {
}
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -359,6 +363,7 @@ export class TicketsService {
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -389,6 +394,7 @@ export class TicketsService {
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -398,9 +404,33 @@ export class TicketsService {
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
/** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */
private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void {
this.prisma.booking.findUnique({
where: { id: booking.id },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
},
}).then((enriched) => {
if (!enriched) return;
this.notifications.sendBoardingPassNotification({
passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null,
contactEmail: (enriched as any).contactEmail ?? null,
contactPhone: (enriched as any).contactPhone ?? null,
bookingRef: enriched.bookingRef,
leg,
booking: enriched,
ticket,
}).catch(() => null);
}).catch(() => null);
}
async getValidationLogs(ticketId: string) {
return this.prisma.gateValidationLog.findMany({
where: { ticketId },

View File

@@ -15,6 +15,7 @@ import {
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from './optional-jwt.guard';
@@ -37,6 +38,7 @@ interface RequestWithUser {
@ApiTags('Fayda Verification')
@Controller('fayda/verification')
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
export class VerifaydaController {
constructor(private readonly service: VerifaydaService) {}

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { WalletService } from './wallet.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -7,6 +8,7 @@ import { JwtGuard } from '../../common/jwt.guard';
@Controller('wallet')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class WalletController {
constructor(private service: WalletService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); }