From 58967e6e3df8777dd4fbd29f79e82aea60406887 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 24 Jun 2026 19:53:05 +0300 Subject: [PATCH] IAM related required updates --- apps/edr-passenger-api/prisma/seed.ts | 2 +- .../src/modules/agents/agents.controller.ts | 10 +- .../src/modules/agents/agents.service.ts | 6 + .../modules/bookings/bookings.controller.ts | 29 ++-- .../src/modules/bookings/bookings.service.ts | 5 +- .../passengers/passengers.controller.ts | 11 +- .../modules/passengers/passengers.service.ts | 138 ++++++++++++------ .../modules/payments/payments.controller.ts | 13 +- .../src/modules/seats/seats.controller.ts | 4 +- .../system-config/system-config.controller.ts | 29 +++- .../src/modules/tickets/tickets.controller.ts | 11 +- .../src/modules/tickets/tickets.service.ts | 1 + .../backoffice/src/app/passengers/page.tsx | 12 +- .../backoffice/src/app/tickets/page.tsx | 52 ++++--- .../backoffice/src/lib/api/index.ts | 4 +- .../src/app/booking/confirmation/page.tsx | 23 +-- .../src/app/booking/passengers/page.tsx | 5 +- .../portal/src/app/booking/payment/page.tsx | 15 +- .../portal/src/lib/api-client.ts | 12 +- 19 files changed, 240 insertions(+), 142 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index a21a009ec..9c5a22b0f 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -496,7 +496,7 @@ async function seedPaymentMethods() { { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' }, { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' }, { type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' }, - { type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' }, + { type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI' }, { type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' }, { type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' }, ]; diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index 378a2a361..139782475 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -1,19 +1,21 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; -// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard. import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; @ApiTags('Agents') @Controller('agents') -// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM -// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only. @UseGuards(IamJwtGuard) @ApiBearerAuth('IAM-auth') export class AgentsController { constructor(private service: AgentsService) {} + @Get('me') + @ApiOperation({ summary: 'Get agent profile for logged-in IAM user' }) + getMe(@Request() req: any) { + return this.service.getMe(req.user?.id ?? req.user?.sub); + } @Post('bookings') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index 1cee0b4e4..4d4b593fb 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -133,4 +133,10 @@ export class AgentsService { take: 20 }); } + + async getMe(iamUserId: string) { + const agent = await this.prisma.agent.findUnique({ where: { iamUserId } }); + if (!agent) throw new NotFoundException('No agent profile found for this user'); + return agent; + } } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index ad4a7cd20..77b3ca1f8 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,6 +1,5 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; -import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { Throttle } from '@nestjs/throttler'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; @@ -47,7 +46,7 @@ export class BookingsController { } @Get('by-device') - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Get bookings by device ID', description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.' @@ -76,8 +75,8 @@ export class BookingsController { } @Get() - @ApiOperation({ - summary: 'List all bookings with filters (Admin/Agent)', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) @@ -102,7 +101,7 @@ export class BookingsController { } @Post('guest') - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)', description: `Creates a booking without requiring login. Supports all four booking types. @@ -256,7 +255,8 @@ export class BookingsController { } @Get('saved-passengers') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get saved passenger profiles', description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)' }) @@ -417,8 +417,8 @@ export class BookingsController { } @Get(':id/usage') - @ApiOperation({ - summary: 'Check if booking is in use', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Returns list of modules/data that reference this booking' }) @ApiResponse({ status: 200, description: 'Usage information retrieved' }) @@ -428,7 +428,8 @@ export class BookingsController { } @Get(':bookingRef') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get booking details by reference (no auth required)', description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts. Works for both guest and authenticated bookings.' }) @@ -452,8 +453,8 @@ export class BookingsController { } @Delete(':id') - @ApiOperation({ - summary: 'Delete booking (admin only)', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Permanently deletes a booking record' }) @ApiResponse({ status: 200, description: 'Booking deleted successfully' }) @@ -463,8 +464,8 @@ export class BookingsController { } @Patch(':id') - @ApiOperation({ - summary: 'Update booking details', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Updates booking information for admin/agent operations' }) @ApiResponse({ status: 200, description: 'Booking updated successfully' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 21532fa44..a81c6eeb0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1027,9 +1027,10 @@ export class BookingsService { ); } - async getByRef(bookingRef: string) { + async getByRef(bookingRefOrId: string) { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ - where: { bookingRef }, + where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 7cd119d42..be3f9ba05 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -19,6 +19,7 @@ export class PassengersController { ) {} @Get() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all passengers with filters (Admin/Agent)', description: 'Returns paginated list of passengers with search filters' @@ -86,6 +87,7 @@ export class PassengersController { } @Post('verify-fayda') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Verify Ethiopian national ID via Verifayda 2.0', description: `**Standalone endpoint for pre-verification of Ethiopian national IDs** @@ -155,6 +157,7 @@ Pre-verify national ID to auto-fill passenger registration form before submissio } @Post('register') + @SetMetadata('isPublic', true) @UseGuards(OptionalJwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ @@ -249,6 +252,7 @@ The API automatically detects: } @Post('save-details') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Bulk save passenger details from booking flow', description: `**Endpoint for saving multiple passengers in a single booking** @@ -347,6 +351,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Patch(':id') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Update passenger details', description: 'Updates passenger information for admin/agent operations' @@ -358,6 +363,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Delete(':id') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete passenger (admin only)', description: 'Permanently deletes a passenger record and associated data' @@ -369,6 +375,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Get(':id/usage') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Check if passenger is in use', description: 'Returns list of modules/data that reference this passenger' diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index a9f6c8f77..893bdb6ae 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -32,46 +32,20 @@ export class PassengersService { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - let iamUserIdFilter: string[] | null = null; + const where: any = {}; - if (search || verified !== undefined) { - const conditions: string[] = []; - const params: any[] = []; - let idx = 1; - - if (search) { - conditions.push(`( - u.email ILIKE $${idx} OR - u.phone_number ILIKE $${idx} OR - (u.name->>'en') ILIKE $${idx} OR - (u.name->>'am') ILIKE $${idx} - )`); - params.push(`%${search}%`); - idx++; - } - - if (verified !== undefined) { - if (verified) { - conditions.push(`u.metadata->>'faydaVerified' = 'true'`); - } else { - conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`); - } - } - - const rows = await this.dataSource.query<{ id: string }[]>( - `SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`, - params, - ); - iamUserIdFilter = rows.map(r => r.id); - - if (iamUserIdFilter.length === 0) { - return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; - } + if (search) { + where.user = { + OR: [ + { email: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search, mode: 'insensitive' } }, + { fullName: { contains: search, mode: 'insensitive' } }, + ], + }; } - const where: any = {}; - if (iamUserIdFilter) { - where.iamUserId = { in: iamUserIdFilter }; + if (verified !== undefined) { + where.user = { ...(where.user ?? {}), faydaVerified: verified }; } const [items, total] = await Promise.all([ @@ -81,8 +55,22 @@ export class PassengersService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { + user: true, loyalty: true, + wallet: true, _count: { select: { bookings: true } }, + bookings: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { + contactEmail: true, + contactPhone: true, + seats: { take: 1, orderBy: { id: 'asc' }, select: { + passengerName: true, dateOfBirth: true, passportNumber: true, + passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true, + }}, + }, + }, }, }), this.prisma.passenger.count({ where }), @@ -97,16 +85,68 @@ export class PassengersService { : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); + // Collect guest contact details for bulk SavedPassengerProfile lookup + const guestContacts = items + .filter(p => !(p as any).user && !p.iamUserId) + .map(p => (p as any).bookings?.[0]) + .filter(Boolean); + const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[]; + const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[]; + + const savedProfiles = (guestEmails.length || guestPhones.length) + ? await this.prisma.savedPassengerProfile.findMany({ + where: { OR: [ + ...(guestEmails.length ? [{ email: { in: guestEmails } }] : []), + ...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []), + ]}, + orderBy: { createdAt: 'desc' }, + }) + : []; + + // Index by email then phone for O(1) lookup + const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s])); + const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s])); + return { items: items.map(passenger => { + const localUser = (passenger as any).user ?? null; const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; - const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; + const faydaVerified = localUser?.faydaVerified === true + || iam?.metadata?.faydaVerified === true + || iam?.metadata?.faydaVerified === 'true'; + const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null; + const guestSeat = guestBooking?.seats?.[0] ?? null; + const savedProfile = guestBooking + ? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null) + : null; return { id: passenger.id, - fullName: iam?.name?.en ?? iam?.name?.am ?? null, - email: iam?.email ?? null, - phone: iam?.phone_number ?? null, + fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null, + email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null, + phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null, + gender: localUser?.gender ?? iam?.metadata?.gender ?? null, + dateOfBirth: localUser?.dateOfBirth + ? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth) + : (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth + ? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0] + : (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))), + nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null, + nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null, + faydaVerified, + faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null, + passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null, + passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null, + passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null, + idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null, verified: faydaVerified, + lastLoginAt: localUser?.lastLoginAt ?? null, + role: localUser?.role ?? null, + loyalty: passenger.loyalty + ? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 } + : null, + wallet: (passenger as any).wallet + ? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' } + : null, loyaltyTier: passenger.loyalty?.tier || 'BRONZE', loyaltyPoints: passenger.loyalty?.pointsBalance || 0, totalBookings: passenger._count.bookings, @@ -384,7 +424,21 @@ export class PassengersService { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - await this.prisma.passenger.delete({ where: { id } }); + await this.prisma.$transaction([ + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }), + this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }), + this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }), + this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }), + this.prisma.notification.deleteMany({ where: { passengerId: id } }), + this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }), + this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }), + this.prisma.journey.deleteMany({ where: { passengerId: id } }), + this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }), + this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), + this.prisma.booking.deleteMany({ where: { passengerId: id } }), + this.prisma.passenger.delete({ where: { id } }), + ]); + return { deleted: true, passengerId: id }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 0b8d6fe18..6a1df8cb1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -7,6 +7,7 @@ import { Post, Query, Res, + SetMetadata, UseGuards, } from "@nestjs/common"; import { @@ -17,7 +18,7 @@ import { ApiOkResponse, ApiProduces, } from "@nestjs/swagger"; -import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; + import { SkipThrottle, Throttle } from "@nestjs/throttler"; import { Response } from "express"; import { PaymentsService } from "./payments.service"; @@ -65,7 +66,7 @@ export class PaymentsController { } @Post("initiate") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Initiate payment with nationality-based payment methods", description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`, @@ -75,14 +76,14 @@ export class PaymentsController { } @Get("intents/:bookingId") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Get payment intent status for a booking" }) getIntent(@Param("bookingId") bookingId: string) { return this.service.getIntentByBookingId(bookingId); } @Get("waafi/return") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " + @@ -123,7 +124,7 @@ export class PaymentsController { } @Get("methods") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "List payment systems supported by the platform", description: @@ -136,7 +137,7 @@ export class PaymentsController { } @Get("checkout") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Browser checkout redirect", description: diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 513017ad6..b946e7537 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -7,6 +7,7 @@ import { Post, Patch, Query, + SetMetadata, UseGuards, } from "@nestjs/common"; import { @@ -17,7 +18,6 @@ import { ApiQuery, ApiResponse, } from "@nestjs/swagger"; -import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; import { SeatsService } from "./seats.service"; import { HoldSeatsDto } from "./seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; @@ -30,6 +30,7 @@ export class SeatsController { // ── Seat Map ────────────────────────────────────────────────────────────── @Get("seatmap/:scheduleId") + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Get seat map filtered by coach type", description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`, @@ -103,6 +104,7 @@ This makes it clear which segment of the route each seat is held for, enabling s } @Post("hold") + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Hold seats for 15 minutes before booking (Public - Guest booking supported)", diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts index 88fe21ffd..6f2bbb73d 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts @@ -1,23 +1,38 @@ -import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; import { SystemConfigService } from './system-config.service'; import { IamGuard } from '../../common/iam-adapter'; import { Roles } from '../../common/roles.decorator'; -@ApiTags('System Config') -@ApiBearerAuth('IAM-auth') -@UseGuards(IamGuard) -@Roles('ADMIN') -@Controller('system-config') +@ApiTags('Config') +@Controller('config') export class SystemConfigController { constructor(private service: SystemConfigService) {} + @Get('fayda-status') + @SetMetadata('isPublic', true) + @SkipThrottle() + @ApiOperation({ summary: 'Get Fayda verification enabled status (public)' }) + getFaydaStatus() { + const enabled = process.env.VERIFAYDA_ENABLED !== 'false'; + return { enabled }; + } + @Get() + @ApiBearerAuth('IAM-auth') + @UseGuards(IamGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Get all system config (admin)' }) getAll() { return this.service.getAll(); } @Patch() + @ApiBearerAuth('IAM-auth') + @UseGuards(IamGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Update system config (admin)' }) update(@Body() body: Record) { return this.service.updateMany(body); } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index d4d68ad1f..029be67e2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -9,7 +9,8 @@ export class TicketsController { constructor(private service: TicketsService) {} @Post('generate/:bookingId') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Generate ticket for booking (confirmation page)', description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.' }) @@ -18,6 +19,7 @@ export class TicketsController { } @Patch('update-seats/:bookingId') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Update ticket seats before final confirmation', description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.' @@ -69,9 +71,8 @@ export class TicketsController { } @Get(':bookingRef') - @ApiOperation({ - summary: 'Get ticket with QR code and passenger details (public)', - }) + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get ticket with QR code and passenger details (public)' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 8e04d1a87..13e31c91a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -85,6 +85,7 @@ export class TicketsService { ticketNumber: t.barcodePayload, bookingRef: t.bookingRef, booking: { + id: t.booking.id, bookingRef: t.booking.bookingRef, status: t.booking.status, bookingType: t.booking.bookingType, diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index f1a4cd2e5..a3a7a4bcd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -91,7 +91,7 @@ export default function PassengersPage() { case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : ''; case 'gender': return p.gender || ''; case 'nationality': return p.nationality || ''; - case 'verified': return p.nationalId ? 'Yes' : 'No'; + case 'verified': return p.faydaVerified ? 'Yes' : 'No'; default: return ''; } }); @@ -117,15 +117,15 @@ export default function PassengersPage() { ), }, - { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone }, - { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, + { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' }, { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' }, + { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' }, { key: 'verified', label: 'Status', render: (p: any) => ( - - {p.nationalId ? 'Verified' : 'Unverified'} + + {p.faydaVerified ? 'Verified' : 'Unverified'} ), }, @@ -192,7 +192,7 @@ export default function PassengersPage() { {selectedPassenger && (() => { const p = selectedPassenger; const isVerified = !!p.faydaVerified || !!p.nationalId; - const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier; + const tier = p.loyalty?.tier || p.loyaltyTier; const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE; return ( diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index fca15313f..68ece3408 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -11,6 +11,7 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; +import { useAuthStore } from '@/lib/auth-store'; export default function TicketsPage() { const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' }); @@ -25,15 +26,23 @@ export default function TicketsPage() { const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); + const { user } = useAuthStore(); + // Excess baggage state const [excessModalOpen, setExcessModalOpen] = useState(false); const [excessTicket, setExcessTicket] = useState(null); const [excessKg, setExcessKg] = useState(''); const [excessCollectCash, setExcessCollectCash] = useState(false); - const [excessAgentId, setExcessAgentId] = useState(''); const [excessError, setExcessError] = useState(null); const [excessResult, setExcessResult] = useState(null); + const { data: agentData } = useQuery({ + queryKey: ['agent-me'], + queryFn: () => apiClient.get('/agents/me'), + enabled: !!user, + retry: false, + }); + const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (

{label}

@@ -105,7 +114,6 @@ export default function TicketsPage() { setExcessTicket(ticket); setExcessKg(''); setExcessCollectCash(false); - setExcessAgentId(''); setExcessError(null); setExcessResult(null); setExcessModalOpen(true); @@ -114,9 +122,11 @@ export default function TicketsPage() { const handleExcessSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!excessTicket) return; + const agentId = agentData?.id; + if (!agentId) { setExcessError('No agent profile found for your account'); return; } await excessMutation.mutateAsync({ - bookingId: excessTicket.bookingId, - agentId: excessAgentId, + bookingId: excessTicket.booking?.id ?? excessTicket.bookingId, + agentId, excessWeightKg: parseInt(excessKg), collectCash: excessCollectCash, }); @@ -371,6 +381,13 @@ export default function TicketsPage() { ]; const actions = [ + { + label: 'Baggage', + onClick: openExcessModal, + variant: 'secondary' as const, + icon: Package, + show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), + }, { label: 'Board', onClick: handleBoard, @@ -411,13 +428,6 @@ export default function TicketsPage() { variant: 'danger' as const, icon: Trash2, }, - { - label: 'Excess Baggage', - onClick: openExcessModal, - variant: 'secondary' as const, - icon: Package, - show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), - }, ]; const stations = stationsData?.items || []; @@ -736,16 +746,16 @@ export default function TicketsPage() {
Booking: {excessTicket?.booking?.bookingRef}
-
- - setExcessAgentId(e.target.value)} - required - /> -
+ {agentData && ( +
+ Agent: {agentData.agentCode} +
+ )} + {!agentData && ( +
+ ⚠ No agent profile linked to your account. +
+ )}
apiClient.get>('/system-config'), - update: (data: Record) => apiClient.patch>('/system-config', data), + getAll: () => apiClient.get>('/config'), + update: (data: Record) => apiClient.patch>('/config', data), }; diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index f7cabc212..5644c9b56 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -4,7 +4,7 @@ export const dynamic = 'force-dynamic'; import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; @@ -29,10 +29,6 @@ export default function ConfirmationPage() { const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const confirmAttempted = useRef(false); - const confirmMutation = useMutation({ - mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }), - }); - const { data: _booking } = useQuery({ queryKey: ['booking', bookingId], queryFn: async (): Promise => { @@ -54,13 +50,20 @@ export default function ConfirmationPage() { useEffect(() => { if (bookingId && !confirmAttempted.current) { confirmAttempted.current = true; - confirmMutation.mutate(); - - apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { - console.error('Failed to generate ticket:', err); + + // Only generate ticket if booking is already CONFIRMED (e.g. wallet payment) + // For other payment methods, ticket is generated by the payment webhook after payment completes + apiClient.get(`/bookings/${bookingId}`).then((data: any) => { + if (data?.status === 'CONFIRMED') { + apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { + console.error('Failed to generate ticket:', err); + }); + } + }).catch((err) => { + console.error('Failed to fetch booking status:', err); }); } - }, [bookingId, confirmMutation]); + }, [bookingId]); const copyPNR = () => { if (pnr) { diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index d2ed43878..0a3e1d65b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -380,6 +380,7 @@ export default function PassengersPage() { const router = useRouter(); const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore(); const { user, isAuthenticated, updateUser } = useAuthStore(); + const isInitialized = useAuthStore((s) => s.isInitialized); const [faydaEnabled, setFaydaEnabled] = useState(true); const [verificationStatus, setVerificationStatus] = useState>({}); const [saving, setSaving] = useState(false); @@ -435,8 +436,8 @@ export default function PassengersPage() { useEffect(() => { const populateForm = async () => { + if (!isInitialized) return; if (!isAuthenticated || !user?.id || !searchCriteria) { - console.log('Missing required data for population'); setFormInitialized(true); return; } @@ -475,7 +476,7 @@ export default function PassengersPage() { }; populateForm(); - }, [isAuthenticated, user, searchCriteria, setValue]); + }, [isInitialized, isAuthenticated, user, searchCriteria, setValue]); const openFaydaVerification = async (index: number) => { if (typeof window === 'undefined') return; diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index def68279c..fc06005f0 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -60,21 +60,14 @@ export default function PaymentPage() { const paymentMutation = useMutation({ mutationFn: async (data: any) => { - // For TELEBIRR and WAAFI, use the initiate endpoint - if (data.method === 'TELEBIRR' || data.method === 'WAAFI') { - const response = await apiClient.post('/payments/initiate', { + // For all payment methods, use the initiate endpoint + try { + return await apiClient.post("/payments/initiate", { bookingId: data.bookingId, method: data.method, paymentMethodId: data.paymentMethodId, - platform: 'web' + platform: 'web', }); - - return response; - } - - // For other payment methods, try the regular payment intent API - try { - return await apiClient.post("/payments/intent", data); } catch (error) { console.log("Payment API not available, using mock payment"); // Mock payment response diff --git a/apps/edr-passenger-web/portal/src/lib/api-client.ts b/apps/edr-passenger-web/portal/src/lib/api-client.ts index 1a29658fd..472c66e70 100644 --- a/apps/edr-passenger-web/portal/src/lib/api-client.ts +++ b/apps/edr-passenger-web/portal/src/lib/api-client.ts @@ -15,21 +15,21 @@ class ApiClient { this.client.interceptors.request.use((config) => { const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; - if (token) { + if (token && token !== 'null' && token !== 'undefined') { config.headers.Authorization = `Bearer ${token}`; } return config; }); + const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me']; + this.client.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - // Don't redirect if it's a login or register request (invalid credentials) - const isAuthEndpoint = error.config?.url?.includes('/auth/login') || - error.config?.url?.includes('/auth/register'); - - if (!isAuthEndpoint && typeof window !== 'undefined') { + const url: string = error.config?.url || ''; + const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p)); + if (!isPublic && typeof window !== 'undefined') { localStorage.removeItem('auth_token'); localStorage.removeItem('auth_user'); window.location.href = '/login';