diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 59d73c55f..6867acb6c 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -1,79 +1,79 @@ -import {Logger, Module, OnApplicationBootstrap} from '@nestjs/common'; -import { ThrottlerModule } from '@nestjs/throttler'; -import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard'; -import { APP_GUARD, APP_FILTER } from '@nestjs/core'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module'; -import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder'; -import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module'; +import { Logger, Module, OnApplicationBootstrap } from "@nestjs/common"; +import { ThrottlerModule } from "@nestjs/throttler"; +import { DynamicThrottlerGuard } from "./common/dynamic-throttler.guard"; +import { APP_GUARD, APP_FILTER } from "@nestjs/core"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module"; +import { DataSeeder } from "@tria-plc/iamapi-common/db/seed/seeder"; +import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import { EDR_PASSENGER_APPLICATION, EDR_PASSENGER_PERMISSIONS, -} from './seed/edr-passenger.seed'; -import { EdrPassengerOrgSeeder } from './seed/edr-passenger-org.seeder'; -import { PassengerStaffUsersSeeder } from './seed/passenger-staff-users.seeder'; -import { PrismaModule } from './common/prisma.module'; -import { AuditModule } from './common/audit.module'; -import { I18nModule } from './common/i18n/i18n.module'; -import { LocaleMiddleware } from './common/i18n/locale.middleware'; -import { DeleteExceptionFilter } from './common/exceptions/delete-exception.filter'; -import appConfig from './config/app.config'; -import dbConfig from './config/database.config'; -import iamDatabaseConfig from './config/iam-database.config'; -import telebirrConfig from './config/telebirr.config'; -import cbeConfig from './config/cbe.config'; -import ebirrConfig from './config/ebirr.config'; -import cardConfig from './config/card.config'; -import waafiConfig from './config/waafi.config'; -import faydaConfig from './config/fayda.config'; -import rabbitmqConfig from './config/rabbitmq.config'; -import { AuthModule } from './modules/auth/auth.module'; -import { StationsModule } from './modules/stations/stations.module'; -import { FleetModule } from './modules/fleet/fleet.module'; -import { SchedulesModule } from './modules/schedules/schedules.module'; -import { SearchModule } from './modules/search/search.module'; -import { SeatsModule } from './modules/seats/seats.module'; -import { BookingsModule } from './modules/bookings/bookings.module'; -import { PaymentsModule } from './modules/payments/payments.module'; -import { TicketsModule } from './modules/tickets/tickets.module'; -import { PassengersModule } from './modules/passengers/passengers.module'; -import { NotificationsModule } from './modules/notifications/notifications.module'; -import { LoyaltyModule } from './modules/loyalty/loyalty.module'; -import { WalletModule } from './modules/wallet/wallet.module'; -import { PromosModule } from './modules/promos/promos.module'; -import { LiveModule } from './modules/live/live.module'; -import { SupportModule } from './modules/support/support.module'; -import { DashboardModule } from './modules/dashboard/dashboard.module'; -import { SegmentsModule } from './modules/segments/segments.module'; -import { AgentsModule } from './modules/agents/agents.module'; -import { ReportsModule } from './modules/reports/reports.module'; -import { FraudModule } from './modules/fraud/fraud.module'; -import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; -import { FareEngineModule } from './modules/fare-engine/fare-engine.module'; -import { VerifaydaModule } from './modules/verifayda/verifayda.module'; -import { AuditModuleFeature } from './modules/audit/audit.module'; -import { CurrenciesModule } from './modules/currencies/currencies.module'; -import { SystemConfigModule } from './modules/system-config/system-config.module'; -import { PackagesModule } from './modules/packages/packages.module'; -import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; -import { HealthModule } from './modules/health/health.module'; -import { TasksModule } from './modules/tasks/tasks.module'; -import { AppReleasesModule } from './modules/app-releases/app-releases.module'; -import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; -import { SegmentFareSeeder } from './seed/segment-fare.seeder'; +} from "./seed/edr-passenger.seed"; +import { EdrPassengerOrgSeeder } from "./seed/edr-passenger-org.seeder"; +import { PassengerStaffUsersSeeder } from "./seed/passenger-staff-users.seeder"; +import { PrismaModule } from "./common/prisma.module"; +import { AuditModule } from "./common/audit.module"; +import { I18nModule } from "./common/i18n/i18n.module"; +import { LocaleMiddleware } from "./common/i18n/locale.middleware"; +import { DeleteExceptionFilter } from "./common/exceptions/delete-exception.filter"; +import appConfig from "./config/app.config"; +import dbConfig from "./config/database.config"; +import iamDatabaseConfig from "./config/iam-database.config"; +import telebirrConfig from "./config/telebirr.config"; +import cbeConfig from "./config/cbe.config"; +import ebirrConfig from "./config/ebirr.config"; +import cardConfig from "./config/card.config"; +import waafiConfig from "./config/waafi.config"; +import faydaConfig from "./config/fayda.config"; +import rabbitmqConfig from "./config/rabbitmq.config"; +import { AuthModule } from "./modules/auth/auth.module"; +import { StationsModule } from "./modules/stations/stations.module"; +import { FleetModule } from "./modules/fleet/fleet.module"; +import { SchedulesModule } from "./modules/schedules/schedules.module"; +import { SearchModule } from "./modules/search/search.module"; +import { SeatsModule } from "./modules/seats/seats.module"; +import { BookingsModule } from "./modules/bookings/bookings.module"; +import { PaymentsModule } from "./modules/payments/payments.module"; +import { TicketsModule } from "./modules/tickets/tickets.module"; +import { PassengersModule } from "./modules/passengers/passengers.module"; +import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { LoyaltyModule } from "./modules/loyalty/loyalty.module"; +import { WalletModule } from "./modules/wallet/wallet.module"; +import { PromosModule } from "./modules/promos/promos.module"; +import { LiveModule } from "./modules/live/live.module"; +import { SupportModule } from "./modules/support/support.module"; +import { DashboardModule } from "./modules/dashboard/dashboard.module"; +import { SegmentsModule } from "./modules/segments/segments.module"; +import { AgentsModule } from "./modules/agents/agents.module"; +import { ReportsModule } from "./modules/reports/reports.module"; +import { FraudModule } from "./modules/fraud/fraud.module"; +import { SeatClassesModule } from "./modules/seat-classes/seat-classes.module"; +import { FareEngineModule } from "./modules/fare-engine/fare-engine.module"; +import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { AuditModuleFeature } from "./modules/audit/audit.module"; +import { CurrenciesModule } from "./modules/currencies/currencies.module"; +import { SystemConfigModule } from "./modules/system-config/system-config.module"; +import { PackagesModule } from "./modules/packages/packages.module"; +import { ExcessBaggageModule } from "./modules/excess-baggage/excess-baggage.module"; +import { HealthModule } from "./modules/health/health.module"; +import { TasksModule } from "./modules/tasks/tasks.module"; +import { AppReleasesModule } from "./modules/app-releases/app-releases.module"; +import { ConfigurableFareModule } from "./modules/configurable-fare/configurable-fare.module"; +import { SegmentFareSeeder } from "./seed/segment-fare.seeder"; import { EOtpType } from "@tria-plc/iamapi-common"; @Module({ imports: [ - ThrottlerModule.forRoot([ - { name: 'auth', ttl: 60_000, limit: 5 }, - { name: 'strict', ttl: 60_000, limit: 20 }, - { name: 'default', ttl: 60_000, limit: 100 }, - ]), + // ThrottlerModule.forRoot([ + // { name: "auth", ttl: 60, limit: 100000 }, + // { name: "strict", ttl: 60, limit: 100000 }, + // { name: "default", ttl: 60, limit: 100000 }, + // ]), ConfigModule.forRoot({ isGlobal: true, load: [ @@ -94,27 +94,27 @@ import { EOtpType } from "@tria-plc/iamapi-common"; TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => - config.get('iamDatabase')!, + config.get("iamDatabase")!, }), TriaIamModule.forRoot({ applications: [EDR_PASSENGER_APPLICATION], permissions: EDR_PASSENGER_PERMISSIONS, otpMessages: { [EOtpType.MFA_LOGIN]: ({ otp }) => - `Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`, + `Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`, [EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) => - `Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`, + `Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`, [EOtpType.RESET_PASSWORD]: ({ route }) => - `Reset your EDR Passenger password using this link: ${route}`, + `Reset your EDR Passenger password using this link: ${route}`, [EOtpType.SET_PASSWORD]: ({ route }) => - `Set your EDR Passenger password using this link: ${route}`, + `Set your EDR Passenger password using this link: ${route}`, }, }), SharedAuthModule, PrismaModule, AuditModule, I18nModule, - AuthModule, + AuthModule, StationsModule, FleetModule, SchedulesModule, @@ -170,22 +170,34 @@ export class AppModule implements OnApplicationBootstrap { try { await this.seeder.run(); } catch (err) { - this.logger.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error( + "[DataSeeder] Seed failed (non-fatal):", + (err as Error).message, + ); } try { await this.edrPassengerOrgSeeder.run(); } catch (err) { - this.logger.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error( + "[EdrPassengerOrgSeeder] Seed failed (non-fatal):", + (err as Error).message, + ); } try { await this.passengerStaffUsersSeeder.run(); } catch (err) { - this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error( + "[PassengerStaffUsersSeeder] Seed failed (non-fatal):", + (err as Error).message, + ); } try { await this.segmentFareSeeder.run(); } catch (err) { - this.logger.error('[SegmentFareSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error( + "[SegmentFareSeeder] Seed failed (non-fatal):", + (err as Error).message, + ); } } } diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index a6f596bf6..7f180137e 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -1,155 +1,232 @@ -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, ResendRegistrationCodeDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; -import { JwtGuard } from '../../common/jwt.guard'; +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, + ResendRegistrationCodeDto, + FaydaRequestPasswordSetupDto, + FaydaVerifyAndLoginDto, +} from "./auth.dto"; +import { JwtGuard } from "../../common/jwt.guard"; -@ApiTags('Passenger Auth') -@Controller('auth') -@Throttle({ auth: { limit: 5, ttl: 60_000 } }) +@ApiTags("Passenger Auth") +@Controller("auth") +// @Throttle({ auth: { limit: 5, ttl: 60_000 } }) export class AuthController { constructor(private passengerAuthService: PassengerAuthService) {} - @Post('register') + @Post("register") @IsPublic() - @ApiOperation({ summary: 'Register new passenger account (sends SMS verification code)' }) + @ApiOperation({ + summary: "Register new passenger account (sends SMS verification code)", + }) @ApiResponse({ status: 201, description: - 'Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.', + "Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.", + }) + @ApiResponse({ + status: 409, + description: "Email or phone already registered", }) - @ApiResponse({ status: 409, description: 'Email or phone already registered' }) @ApiBody({ type: RegisterDto }) register(@Request() req: any, @Body() dto: RegisterDto) { return this.passengerAuthService.register(dto, req); } - @Post('register/resend-code') + @Post("register/resend-code") @IsPublic() @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Resend the registration verification code for a pending account' }) - @ApiResponse({ status: 200, description: 'Verification code re-sent if the account is pending.' }) + @ApiOperation({ + summary: "Resend the registration verification code for a pending account", + }) + @ApiResponse({ + status: 200, + description: "Verification code re-sent if the account is pending.", + }) @ApiBody({ type: ResendRegistrationCodeDto }) - resendRegistrationCode(@Request() req: any, @Body() dto: ResendRegistrationCodeDto) { + resendRegistrationCode( + @Request() req: any, + @Body() dto: ResendRegistrationCodeDto, + ) { return this.passengerAuthService.resendRegistrationCode(dto, req); } - @Post('login') + @Post("login") @IsPublic() @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Login with email and password' }) - @ApiResponse({ status: 200, description: 'Login successful. Returns token + passengerId.' }) - @ApiResponse({ status: 401, description: 'Invalid credentials' }) + @ApiOperation({ summary: "Login with email and password" }) + @ApiResponse({ + status: 200, + description: "Login successful. Returns token + passengerId.", + }) + @ApiResponse({ status: 401, description: "Invalid credentials" }) @ApiBody({ type: LoginDto }) login(@Request() req: any, @Body() dto: LoginDto) { return this.passengerAuthService.login(dto, req); } - @Post('logout') + @Post("logout") @HttpCode(HttpStatus.OK) @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Logout current user' }) - @ApiResponse({ status: 200, description: 'Logout successful' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Logout current user" }) + @ApiResponse({ status: 200, description: "Logout successful" }) + @ApiResponse({ status: 401, description: "Unauthorized" }) logout(@Request() req: any) { - if (!req.user?.id) throw new UnauthorizedException('User not authenticated'); + if (!req.user?.id) + throw new UnauthorizedException("User not authenticated"); return this.passengerAuthService.logout(req.user, req); } - @Get('me') + @Get("me") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: '[DEV] Inspect raw JWT payload — shows full req.user from JwtGuard' }) - @ApiResponse({ status: 200, description: 'Returns the full req.user object set by JwtGuard' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: + "[DEV] Inspect raw JWT payload — shows full req.user from JwtGuard", + }) + @ApiResponse({ + status: 200, + description: "Returns the full req.user object set by JwtGuard", + }) + @ApiResponse({ status: 401, description: "Unauthorized" }) getMe(@Request() req: any) { return { user: req.user }; } - @Get('profile') + @Get("profile") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get current user profile' }) - @ApiResponse({ status: 200, description: 'User profile retrieved successfully' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get current user profile" }) + @ApiResponse({ + status: 200, + description: "User profile retrieved successfully", + }) + @ApiResponse({ status: 401, description: "Unauthorized" }) getProfile(@Request() req: any) { const userId = req.user?.id; - if (!userId) throw new UnauthorizedException('User not authenticated'); + if (!userId) throw new UnauthorizedException("User not authenticated"); return this.passengerAuthService.getProfile(userId); } // TODO: admin user management endpoints — implement when admin module is ready - @Get('users') + @Get("users") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'List all users (admin)' }) + @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, + @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, + search, + role, + status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20, }); } - @Post('users') + @Post("users") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Create user (admin)' }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Create user (admin)" }) createUser(@Body() body: any) { return this.passengerAuthService.createUser(body); } - @Patch('users/:id') + @Patch("users/:id") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update user (admin)' }) - updateUser(@Param('id') id: string, @Body() body: any) { + @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') + @Delete("users/:id") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Delete user (admin)' }) - deleteUser(@Param('id') id: string) { + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Delete user (admin)" }) + deleteUser(@Param("id") id: string) { return this.passengerAuthService.deleteUser(id); } - @Post('users/:id/reset-password') + @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 }) { + @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); } - @Post('fayda/request-password-setup') + @Post("fayda/request-password-setup") @IsPublic() @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Send OTP to phone for Fayda-verified account password setup' }) - @ApiResponse({ status: 200, description: 'OTP sent to registered phone number' }) + @ApiOperation({ + summary: "Send OTP to phone for Fayda-verified account password setup", + }) + @ApiResponse({ + status: 200, + description: "OTP sent to registered phone number", + }) @ApiBody({ type: FaydaRequestPasswordSetupDto }) - requestFaydaPasswordSetup(@Body() dto: FaydaRequestPasswordSetupDto, @Request() req: any) { - return this.passengerAuthService.requestFaydaPasswordSetup(dto.phoneNumber, req); + requestFaydaPasswordSetup( + @Body() dto: FaydaRequestPasswordSetupDto, + @Request() req: any, + ) { + return this.passengerAuthService.requestFaydaPasswordSetup( + dto.phoneNumber, + req, + ); } - @Post('fayda/verify-and-login') + @Post("fayda/verify-and-login") @IsPublic() @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Verify OTP and receive session token for Fayda-verified account' }) - @ApiResponse({ status: 200, description: 'Returns token + requiresPassword flag. Use token with POST /v1/auth/set-fayda-password.' }) + @ApiOperation({ + summary: "Verify OTP and receive session token for Fayda-verified account", + }) + @ApiResponse({ + status: 200, + description: + "Returns token + requiresPassword flag. Use token with POST /v1/auth/set-fayda-password.", + }) @ApiBody({ type: FaydaVerifyAndLoginDto }) verifyFaydaAndLogin(@Body() dto: FaydaVerifyAndLoginDto) { - return this.passengerAuthService.verifyFaydaAndLogin(dto.phoneNumber, dto.otp); + return this.passengerAuthService.verifyFaydaAndLogin( + dto.phoneNumber, + dto.otp, + ); } } 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 7789fd56b..1d30c4257 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,41 +1,85 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, SetMetadata, BadRequestException, UnauthorizedException } 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'; -import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; -import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; -import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerAdmin } from '../../common/passenger-guards'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Patch, + UseGuards, + Query, + Req, + SetMetadata, + BadRequestException, + UnauthorizedException, +} 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"; +import { + CreateBookingDto, + ModifyBookingDto, + CancelBookingDto, +} from "./bookings.dto"; +import { + CreateGuestBookingDto, + GetSavedPassengersDto, +} from "./guest-booking.dto"; +import { JwtGuard } from "../../common/jwt.guard"; +import { PassengerAdmin } from "../../common/passenger-guards"; -@ApiTags('Booking') -@Controller('bookings') -@Throttle({ strict: { limit: 20, ttl: 60_000 } }) +@ApiTags("Booking") +@Controller("bookings") +// @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class BookingsController { constructor( private service: BookingsService, private guestService: GuestBookingService, ) {} - - @Get('my') + + @Get("my") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Get logged-in user\'s booking history', - description: 'Returns all bookings for the authenticated user with schedule and payment details' + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Get logged-in user's booking history", + description: + "Returns all bookings for the authenticated user with schedule and payment details", + }) + @ApiQuery({ + name: "search", + required: false, + description: "Search by booking reference or station names", + }) + @ApiQuery({ + name: "status", + required: false, + description: "Filter by booking status", + }) + @ApiQuery({ name: "page", required: false, description: "Page number" }) + @ApiQuery({ + name: "pageSize", + required: false, + description: "Items per page", + }) + @ApiResponse({ + status: 200, + description: "List of user bookings with schedule and passenger details", }) - @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' }) - @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) - @ApiQuery({ name: 'page', required: false, description: 'Page number' }) - @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) - @ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' }) getMyBookings( @Req() req: any, - @Query('search') search?: string, - @Query('status') status?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, + @Query("search") search?: string, + @Query("status") status?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, ) { const iamUserId = req.user?.id; if (!iamUserId) throw new UnauthorizedException(); @@ -43,81 +87,103 @@ export class BookingsController { search, status, page: page ? parseInt(page) : 1, - pageSize: pageSize ? parseInt(pageSize) : 20 + pageSize: pageSize ? parseInt(pageSize) : 20, }); } - @Get('by-device') - @SetMetadata('isPublic', true) + @Get("by-device") + @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.' + 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.", }) - @ApiQuery({ name: 'deviceId', required: true, description: 'Device identifier' }) - @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' }) - @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) - @ApiQuery({ name: 'page', required: false, description: 'Page number' }) - @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) - @ApiResponse({ status: 200, description: 'List of guest bookings and saved passengers for device' }) - @ApiResponse({ status: 400, description: 'Device ID is required' }) + @ApiQuery({ + name: "deviceId", + required: true, + description: "Device identifier", + }) + @ApiQuery({ + name: "search", + required: false, + description: "Search by booking reference or station names", + }) + @ApiQuery({ + name: "status", + required: false, + description: "Filter by booking status", + }) + @ApiQuery({ name: "page", required: false, description: "Page number" }) + @ApiQuery({ + name: "pageSize", + required: false, + description: "Items per page", + }) + @ApiResponse({ + status: 200, + description: "List of guest bookings and saved passengers for device", + }) + @ApiResponse({ status: 400, description: "Device ID is required" }) getByDevice( - @Query('deviceId') deviceId?: string, - @Query('search') search?: string, - @Query('status') status?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, + @Query("deviceId") deviceId?: string, + @Query("search") search?: string, + @Query("status") status?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, ) { - if (!deviceId) throw new BadRequestException('Device ID is required'); - return this.service.findByDeviceId(deviceId, { - search, - status, - page: page ? parseInt(page) : 1, - pageSize: pageSize ? parseInt(pageSize) : 20 + if (!deviceId) throw new BadRequestException("Device ID is required"); + return this.service.findByDeviceId(deviceId, { + search, + status, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20, }); } - + @Get() - @SetMetadata('isPublic', true) + @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.' + 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 }) - @ApiQuery({ name: 'status', required: false }) - @ApiQuery({ name: 'returnLegStatus', required: false }) - @ApiQuery({ name: 'bookingType', required: false }) - @ApiQuery({ name: 'paymentStatus', required: false }) - @ApiQuery({ name: 'dateFrom', required: false }) - @ApiQuery({ name: 'dateTo', required: false }) - @ApiQuery({ name: 'page', required: false }) - @ApiQuery({ name: 'pageSize', required: false }) + @ApiQuery({ name: "search", required: false }) + @ApiQuery({ name: "status", required: false }) + @ApiQuery({ name: "returnLegStatus", required: false }) + @ApiQuery({ name: "bookingType", required: false }) + @ApiQuery({ name: "paymentStatus", required: false }) + @ApiQuery({ name: "dateFrom", required: false }) + @ApiQuery({ name: "dateTo", required: false }) + @ApiQuery({ name: "page", required: false }) + @ApiQuery({ name: "pageSize", required: false }) findAll( - @Query('search') search?: string, - @Query('status') status?: string, - @Query('returnLegStatus') returnLegStatus?: string, - @Query('bookingType') bookingType?: string, - @Query('paymentStatus') paymentStatus?: string, - @Query('dateFrom') dateFrom?: string, - @Query('dateTo') dateTo?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, + @Query("search") search?: string, + @Query("status") status?: string, + @Query("returnLegStatus") returnLegStatus?: string, + @Query("bookingType") bookingType?: string, + @Query("paymentStatus") paymentStatus?: string, + @Query("dateFrom") dateFrom?: string, + @Query("dateTo") dateTo?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, ) { - return this.service.findAll({ - search, + return this.service.findAll({ + search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, - page: page ? parseInt(page) : 1, - pageSize: pageSize ? parseInt(pageSize) : 20 + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20, }); } - - @Post('guest') - @SetMetadata('isPublic', true) + + @Post("guest") + @SetMetadata("isPublic", true) @ApiOperation({ - summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)', + 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. **bookingType: ONE_WAY (default)** @@ -138,152 +204,170 @@ export class BookingsController { **Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised. -**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.` +**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.`, }) @ApiBody({ type: CreateGuestBookingDto, examples: { ONE_WAY: { - summary: 'ONE_WAY — single direct journey (guest)', + summary: "ONE_WAY — single direct journey (guest)", value: { - scheduleId: 'schedule-uuid', - holdId: 'hold-uuid', - originStationId: 'station-uuid', - destinationStationId: 'station-uuid', - seatClassId: 'seat-class-uuid', - bookingType: 'ONE_WAY', - displayCurrency: 'ETB', - passengers: [{ - seatId: 'seat-uuid', - passengerName: 'Abebe Kebede', - dateOfBirth: '1990-05-15', - idDocumentType: 'NATIONAL_ID', - idDocumentNumber: 'ET123456789', - nationality: 'Ethiopian', - phone: '+251911234567', - email: 'abebe@email.com', - }], + scheduleId: "schedule-uuid", + holdId: "hold-uuid", + originStationId: "station-uuid", + destinationStationId: "station-uuid", + seatClassId: "seat-class-uuid", + bookingType: "ONE_WAY", + displayCurrency: "ETB", + passengers: [ + { + seatId: "seat-uuid", + passengerName: "Abebe Kebede", + dateOfBirth: "1990-05-15", + idDocumentType: "NATIONAL_ID", + idDocumentNumber: "ET123456789", + nationality: "Ethiopian", + phone: "+251911234567", + email: "abebe@email.com", + }, + ], savePassengerDetails: true, - deviceId: 'device-uuid-123', + deviceId: "device-uuid-123", }, }, ROUND_TRIP: { - summary: 'ROUND_TRIP — outbound + return, single PNR (guest)', + summary: "ROUND_TRIP — outbound + return, single PNR (guest)", value: { - scheduleId: 'outbound-schedule-uuid', - holdId: 'outbound-hold-uuid', - originStationId: 'addis-station-uuid', - destinationStationId: 'djibouti-station-uuid', - seatClassId: 'seat-class-uuid', - bookingType: 'ROUND_TRIP', - returnScheduleId: 'return-schedule-uuid', - returnHoldId: 'return-hold-uuid', - returnOriginStationId: 'djibouti-station-uuid', - returnDestinationStationId: 'addis-station-uuid', - returnSeatClassId: 'seat-class-uuid', - displayCurrency: 'ETB', - passengers: [{ - seatId: 'outbound-seat-uuid', - returnSeatId: 'return-seat-uuid', - passengerName: 'Abebe Kebede', - dateOfBirth: '1990-05-15', - idDocumentType: 'NATIONAL_ID', - idDocumentNumber: 'ET123456789', - nationality: 'Ethiopian', - phone: '+251911234567', - }], + scheduleId: "outbound-schedule-uuid", + holdId: "outbound-hold-uuid", + originStationId: "addis-station-uuid", + destinationStationId: "djibouti-station-uuid", + seatClassId: "seat-class-uuid", + bookingType: "ROUND_TRIP", + returnScheduleId: "return-schedule-uuid", + returnHoldId: "return-hold-uuid", + returnOriginStationId: "djibouti-station-uuid", + returnDestinationStationId: "addis-station-uuid", + returnSeatClassId: "seat-class-uuid", + displayCurrency: "ETB", + passengers: [ + { + seatId: "outbound-seat-uuid", + returnSeatId: "return-seat-uuid", + passengerName: "Abebe Kebede", + dateOfBirth: "1990-05-15", + idDocumentType: "NATIONAL_ID", + idDocumentNumber: "ET123456789", + nationality: "Ethiopian", + phone: "+251911234567", + }, + ], savePassengerDetails: true, - deviceId: 'device-uuid-123', + deviceId: "device-uuid-123", }, }, TRANSIT: { - summary: 'TRANSIT — connecting train, single PNR (guest)', + summary: "TRANSIT — connecting train, single PNR (guest)", value: { - scheduleId: 'leg1-schedule-uuid', - holdId: 'leg1-hold-uuid', - originStationId: 'addis-station-uuid', - destinationStationId: 'diredawa-station-uuid', - seatClassId: 'seat-class-uuid', - bookingType: 'TRANSIT', - leg2ScheduleId: 'leg2-schedule-uuid', - leg2HoldId: 'leg2-hold-uuid', - transitStationId: 'diredawa-station-uuid', - leg2DestinationStationId: 'djibouti-station-uuid', - displayCurrency: 'ETB', - passengers: [{ - seatId: 'leg1-seat-uuid', - leg2SeatId: 'leg2-seat-uuid', - passengerName: 'Abebe Kebede', - dateOfBirth: '1990-05-15', - idDocumentType: 'NATIONAL_ID', - idDocumentNumber: 'ET123456789', - nationality: 'Ethiopian', - phone: '+251911234567', - }], - deviceId: 'device-uuid-123', + scheduleId: "leg1-schedule-uuid", + holdId: "leg1-hold-uuid", + originStationId: "addis-station-uuid", + destinationStationId: "diredawa-station-uuid", + seatClassId: "seat-class-uuid", + bookingType: "TRANSIT", + leg2ScheduleId: "leg2-schedule-uuid", + leg2HoldId: "leg2-hold-uuid", + transitStationId: "diredawa-station-uuid", + leg2DestinationStationId: "djibouti-station-uuid", + displayCurrency: "ETB", + passengers: [ + { + seatId: "leg1-seat-uuid", + leg2SeatId: "leg2-seat-uuid", + passengerName: "Abebe Kebede", + dateOfBirth: "1990-05-15", + idDocumentType: "NATIONAL_ID", + idDocumentNumber: "ET123456789", + nationality: "Ethiopian", + phone: "+251911234567", + }, + ], + deviceId: "device-uuid-123", }, }, ROUND_TRIP_TRANSIT: { - summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)', + summary: + "ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)", value: { - scheduleId: 'ob-leg1-schedule-uuid', - holdId: 'ob-leg1-hold-uuid', - originStationId: 'addis-station-uuid', - destinationStationId: 'diredawa-station-uuid', - seatClassId: 'seat-class-uuid', - bookingType: 'ROUND_TRIP_TRANSIT', - leg2ScheduleId: 'ob-leg2-schedule-uuid', - leg2HoldId: 'ob-leg2-hold-uuid', - transitStationId: 'diredawa-station-uuid', - leg2DestinationStationId: 'djibouti-station-uuid', - returnScheduleId: 'ret-leg1-schedule-uuid', - returnHoldId: 'ret-leg1-hold-uuid', - returnOriginStationId: 'djibouti-station-uuid', - returnDestinationStationId: 'diredawa-station-uuid', - returnLeg2ScheduleId: 'ret-leg2-schedule-uuid', - returnLeg2HoldId: 'ret-leg2-hold-uuid', - returnTransitStationId: 'diredawa-station-uuid', - returnLeg2DestinationStationId: 'addis-station-uuid', - displayCurrency: 'ETB', - passengers: [{ - seatId: 'ob-leg1-seat-uuid', - leg2SeatId: 'ob-leg2-seat-uuid', - returnSeatId: 'ret-leg1-seat-uuid', - returnLeg2SeatId: 'ret-leg2-seat-uuid', - passengerName: 'Abebe Kebede', - dateOfBirth: '1990-05-15', - idDocumentType: 'NATIONAL_ID', - idDocumentNumber: 'ET123456789', - nationality: 'Ethiopian', - phone: '+251911234567', - }], - deviceId: 'device-uuid-123', + scheduleId: "ob-leg1-schedule-uuid", + holdId: "ob-leg1-hold-uuid", + originStationId: "addis-station-uuid", + destinationStationId: "diredawa-station-uuid", + seatClassId: "seat-class-uuid", + bookingType: "ROUND_TRIP_TRANSIT", + leg2ScheduleId: "ob-leg2-schedule-uuid", + leg2HoldId: "ob-leg2-hold-uuid", + transitStationId: "diredawa-station-uuid", + leg2DestinationStationId: "djibouti-station-uuid", + returnScheduleId: "ret-leg1-schedule-uuid", + returnHoldId: "ret-leg1-hold-uuid", + returnOriginStationId: "djibouti-station-uuid", + returnDestinationStationId: "diredawa-station-uuid", + returnLeg2ScheduleId: "ret-leg2-schedule-uuid", + returnLeg2HoldId: "ret-leg2-hold-uuid", + returnTransitStationId: "diredawa-station-uuid", + returnLeg2DestinationStationId: "addis-station-uuid", + displayCurrency: "ETB", + passengers: [ + { + seatId: "ob-leg1-seat-uuid", + leg2SeatId: "ob-leg2-seat-uuid", + returnSeatId: "ret-leg1-seat-uuid", + returnLeg2SeatId: "ret-leg2-seat-uuid", + passengerName: "Abebe Kebede", + dateOfBirth: "1990-05-15", + idDocumentType: "NATIONAL_ID", + idDocumentNumber: "ET123456789", + nationality: "Ethiopian", + phone: "+251911234567", + }, + ], + deviceId: "device-uuid-123", }, }, }, }) - @ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' }) - @ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' }) + @ApiResponse({ + status: 201, + description: "Booking created successfully with fareBreakdown", + }) + @ApiResponse({ + status: 400, + description: + "Missing required seat IDs for bookingType, or Verifayda verification failed", + }) createGuest(@Req() req: any, @Body() dto: CreateGuestBookingDto) { return this.guestService.createGuestBooking(dto, req); } - @Get('saved-passengers') - @SetMetadata('isPublic', true) + @Get("saved-passengers") + @SetMetadata("isPublic", true) @ApiOperation({ - summary: 'Get saved passenger profiles', - description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)' + summary: "Get saved passenger profiles", + description: + "Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)", }) - @ApiResponse({ status: 200, description: 'List of saved passenger profiles' }) + @ApiResponse({ status: 200, description: "List of saved passenger profiles" }) getSavedPassengers(@Query() query: GetSavedPassengersDto) { return this.guestService.getSavedPassengers(undefined, query.deviceId); } - + @Post() @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT', + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: + "Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT", description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required. **ONE_WAY** @@ -306,164 +390,193 @@ export class BookingsController { **Age-Based Pricing (all types)** - ADULT (≥5 years): full fare per leg -- CHILD (<5 years): first child FREE per booking, subsequent children full fare` +- CHILD (<5 years): first child FREE per booking, subsequent children full fare`, }) @ApiBody({ type: CreateBookingDto, examples: { ONE_WAY: { - summary: 'ONE_WAY — single direct journey', + summary: "ONE_WAY — single direct journey", value: { - passengerId: 'passenger-uuid', - scheduleId: 'schedule-uuid', - holdId: 'hold-uuid', - originStationId: 'station-uuid', - destinationStationId: 'station-uuid', - seatClassId: 'seat-class-uuid', - bookingType: 'ONE_WAY', - displayCurrency: 'ETB', - passengers: [{ - seatId: 'seat-uuid', - passengerName: 'Abebe Kebede', - dateOfBirth: '1990-05-15', - idDocumentType: 'NATIONAL_ID', - idDocumentNumber: 'ET123456789', - nationality: 'Ethiopian', - }], + passengerId: "passenger-uuid", + scheduleId: "schedule-uuid", + holdId: "hold-uuid", + originStationId: "station-uuid", + destinationStationId: "station-uuid", + seatClassId: "seat-class-uuid", + bookingType: "ONE_WAY", + displayCurrency: "ETB", + passengers: [ + { + seatId: "seat-uuid", + passengerName: "Abebe Kebede", + dateOfBirth: "1990-05-15", + idDocumentType: "NATIONAL_ID", + idDocumentNumber: "ET123456789", + nationality: "Ethiopian", + }, + ], }, }, ROUND_TRIP: { - summary: 'ROUND_TRIP — outbound + return, single PNR', + summary: "ROUND_TRIP — outbound + return, single PNR", value: { - passengerId: 'passenger-uuid', - scheduleId: 'outbound-schedule-uuid', - holdId: 'outbound-hold-uuid', - originStationId: 'addis-station-uuid', - destinationStationId: 'djibouti-station-uuid', - seatClassId: 'seat-class-uuid', - bookingType: 'ROUND_TRIP', - returnScheduleId: 'return-schedule-uuid', - returnHoldId: 'return-hold-uuid', - returnOriginStationId: 'djibouti-station-uuid', - returnDestinationStationId: 'addis-station-uuid', - returnSeatClassId: 'seat-class-uuid', - displayCurrency: 'ETB', - passengers: [{ - seatId: 'outbound-seat-uuid', - returnSeatId: 'return-seat-uuid', - passengerName: 'Abebe Kebede', - dateOfBirth: '1990-05-15', - idDocumentType: 'NATIONAL_ID', - idDocumentNumber: 'ET123456789', - nationality: 'Ethiopian', - }], + passengerId: "passenger-uuid", + scheduleId: "outbound-schedule-uuid", + holdId: "outbound-hold-uuid", + originStationId: "addis-station-uuid", + destinationStationId: "djibouti-station-uuid", + seatClassId: "seat-class-uuid", + bookingType: "ROUND_TRIP", + returnScheduleId: "return-schedule-uuid", + returnHoldId: "return-hold-uuid", + returnOriginStationId: "djibouti-station-uuid", + returnDestinationStationId: "addis-station-uuid", + returnSeatClassId: "seat-class-uuid", + displayCurrency: "ETB", + passengers: [ + { + seatId: "outbound-seat-uuid", + returnSeatId: "return-seat-uuid", + passengerName: "Abebe Kebede", + dateOfBirth: "1990-05-15", + idDocumentType: "NATIONAL_ID", + idDocumentNumber: "ET123456789", + nationality: "Ethiopian", + }, + ], }, }, TRANSIT: { - summary: 'TRANSIT — connecting train, single PNR', + summary: "TRANSIT — connecting train, single PNR", value: { - passengerId: 'passenger-uuid', - scheduleId: 'leg1-schedule-uuid', - holdId: 'leg1-hold-uuid', - originStationId: 'addis-station-uuid', - destinationStationId: 'diredawa-station-uuid', - seatClassId: 'seat-class-uuid', - bookingType: 'TRANSIT', - leg2ScheduleId: 'leg2-schedule-uuid', - leg2HoldId: 'leg2-hold-uuid', - transitStationId: 'diredawa-station-uuid', - leg2DestinationStationId: 'djibouti-station-uuid', - displayCurrency: 'ETB', - passengers: [{ - seatId: 'leg1-seat-uuid', - leg2SeatId: 'leg2-seat-uuid', - passengerName: 'Abebe Kebede', - dateOfBirth: '1990-05-15', - idDocumentType: 'NATIONAL_ID', - idDocumentNumber: 'ET123456789', - nationality: 'Ethiopian', - }], + passengerId: "passenger-uuid", + scheduleId: "leg1-schedule-uuid", + holdId: "leg1-hold-uuid", + originStationId: "addis-station-uuid", + destinationStationId: "diredawa-station-uuid", + seatClassId: "seat-class-uuid", + bookingType: "TRANSIT", + leg2ScheduleId: "leg2-schedule-uuid", + leg2HoldId: "leg2-hold-uuid", + transitStationId: "diredawa-station-uuid", + leg2DestinationStationId: "djibouti-station-uuid", + displayCurrency: "ETB", + passengers: [ + { + seatId: "leg1-seat-uuid", + leg2SeatId: "leg2-seat-uuid", + passengerName: "Abebe Kebede", + dateOfBirth: "1990-05-15", + idDocumentType: "NATIONAL_ID", + idDocumentNumber: "ET123456789", + nationality: "Ethiopian", + }, + ], }, }, ROUND_TRIP_TRANSIT: { - summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds', + summary: + "ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds", value: { - passengerId: 'passenger-uuid', - scheduleId: 'ob-leg1-schedule-uuid', - holdId: 'ob-leg1-hold-uuid', - originStationId: 'addis-station-uuid', - destinationStationId: 'diredawa-station-uuid', - seatClassId: 'seat-class-uuid', - bookingType: 'ROUND_TRIP_TRANSIT', - leg2ScheduleId: 'ob-leg2-schedule-uuid', - leg2HoldId: 'ob-leg2-hold-uuid', - transitStationId: 'diredawa-station-uuid', - leg2DestinationStationId: 'djibouti-station-uuid', - returnScheduleId: 'ret-leg1-schedule-uuid', - returnHoldId: 'ret-leg1-hold-uuid', - returnOriginStationId: 'djibouti-station-uuid', - returnDestinationStationId: 'diredawa-station-uuid', - returnLeg2ScheduleId: 'ret-leg2-schedule-uuid', - returnLeg2HoldId: 'ret-leg2-hold-uuid', - returnTransitStationId: 'diredawa-station-uuid', - returnLeg2DestinationStationId: 'addis-station-uuid', - displayCurrency: 'ETB', - passengers: [{ - seatId: 'ob-leg1-seat-uuid', - leg2SeatId: 'ob-leg2-seat-uuid', - returnSeatId: 'ret-leg1-seat-uuid', - returnLeg2SeatId: 'ret-leg2-seat-uuid', - passengerName: 'Abebe Kebede', - dateOfBirth: '1990-05-15', - idDocumentType: 'NATIONAL_ID', - idDocumentNumber: 'ET123456789', - nationality: 'Ethiopian', - }], + passengerId: "passenger-uuid", + scheduleId: "ob-leg1-schedule-uuid", + holdId: "ob-leg1-hold-uuid", + originStationId: "addis-station-uuid", + destinationStationId: "diredawa-station-uuid", + seatClassId: "seat-class-uuid", + bookingType: "ROUND_TRIP_TRANSIT", + leg2ScheduleId: "ob-leg2-schedule-uuid", + leg2HoldId: "ob-leg2-hold-uuid", + transitStationId: "diredawa-station-uuid", + leg2DestinationStationId: "djibouti-station-uuid", + returnScheduleId: "ret-leg1-schedule-uuid", + returnHoldId: "ret-leg1-hold-uuid", + returnOriginStationId: "djibouti-station-uuid", + returnDestinationStationId: "diredawa-station-uuid", + returnLeg2ScheduleId: "ret-leg2-schedule-uuid", + returnLeg2HoldId: "ret-leg2-hold-uuid", + returnTransitStationId: "diredawa-station-uuid", + returnLeg2DestinationStationId: "addis-station-uuid", + displayCurrency: "ETB", + passengers: [ + { + seatId: "ob-leg1-seat-uuid", + leg2SeatId: "ob-leg2-seat-uuid", + returnSeatId: "ret-leg1-seat-uuid", + returnLeg2SeatId: "ret-leg2-seat-uuid", + passengerName: "Abebe Kebede", + dateOfBirth: "1990-05-15", + idDocumentType: "NATIONAL_ID", + idDocumentNumber: "ET123456789", + nationality: "Ethiopian", + }, + ], }, }, }, }) - @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) - @ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' }) - @ApiResponse({ status: 404, description: 'Schedule or seat hold not found' }) - create(@Body() dto: CreateBookingDto) { - return this.service.create(dto); - } - - @Get(':id/usage') - @SetMetadata('isPublic', true) - @ApiOperation({ - description: 'Returns list of modules/data that reference this booking' + @ApiResponse({ + status: 201, + description: "Booking created with fare breakdown", }) - @ApiResponse({ status: 200, description: 'Usage information retrieved' }) - @ApiResponse({ status: 404, description: 'Booking not found' }) - checkUsage(@Param('id') id: string) { + @ApiResponse({ + status: 400, + description: + "Missing required fields for bookingType, or Verifayda verification failed", + }) + @ApiResponse({ status: 404, description: "Schedule or seat hold not found" }) + create(@Body() dto: CreateBookingDto) { + return this.service.create(dto); + } + + @Get(":id/usage") + @SetMetadata("isPublic", true) + @ApiOperation({ + 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('by-phone') - @SetMetadata('isPublic', true) + @Get("by-phone") + @SetMetadata("isPublic", true) @ApiOperation({ - summary: 'Find bookings by phone number (no auth required)', + summary: "Find bookings by phone number (no auth required)", description: `Returns all bookings where the contact phone matches the provided number. Accepts Ethiopian local format (09XXXXXXXX) and international format (+251XXXXXXXXX). -Results are ordered most-recent first. Use the returned \`bookingRef\` to open booking detail.` +Results are ordered most-recent first. Use the returned \`bookingRef\` to open booking detail.`, }) - @ApiQuery({ name: 'phone', required: true, description: 'Phone number in local (09…) or international (+251…) format' }) - @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) - @ApiQuery({ name: 'page', required: false }) - @ApiQuery({ name: 'pageSize', required: false }) - @ApiResponse({ status: 200, description: 'Paginated list of bookings for this phone number' }) - @ApiResponse({ status: 400, description: 'Phone number missing or invalid' }) + @ApiQuery({ + name: "phone", + required: true, + description: "Phone number in local (09…) or international (+251…) format", + }) + @ApiQuery({ + name: "status", + required: false, + description: "Filter by booking status", + }) + @ApiQuery({ name: "page", required: false }) + @ApiQuery({ name: "pageSize", required: false }) + @ApiResponse({ + status: 200, + description: "Paginated list of bookings for this phone number", + }) + @ApiResponse({ status: 400, description: "Phone number missing or invalid" }) findByPhone( - @Query('phone') phone?: string, - @Query('status') status?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, + @Query("phone") phone?: string, + @Query("status") status?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, ) { - if (!phone?.trim()) throw new BadRequestException('Phone number is required'); - const digits = phone.replace(/[^\d]/g, ''); - if (digits.length < 7) throw new BadRequestException('Phone number is too short'); + if (!phone?.trim()) + throw new BadRequestException("Phone number is required"); + const digits = phone.replace(/[^\d]/g, ""); + if (digits.length < 7) + throw new BadRequestException("Phone number is too short"); return this.service.findByPhone(phone.trim(), { status, page: page ? parseInt(page) : 1, @@ -471,65 +584,82 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b }); } - @Get(':bookingRef') - @SetMetadata('isPublic', true) + @Get(":bookingRef") + @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.' + 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.", }) - @ApiResponse({ status: 200, description: 'Booking details with adult/child counts and currency conversion' }) - @ApiResponse({ status: 404, description: 'Booking not found' }) - getByRef(@Param('bookingRef') ref: string) { - return this.service.getByRef(ref); + @ApiResponse({ + status: 200, + description: + "Booking details with adult/child counts and currency conversion", + }) + @ApiResponse({ status: 404, description: "Booking not found" }) + getByRef(@Param("bookingRef") ref: string) { + return this.service.getByRef(ref); } - - @Patch(':bookingRef/modify') + + @Patch(":bookingRef/modify") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Modify booking seats or trip', - description: 'Allows modification of confirmed bookings before departure' + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Modify booking seats or trip", + description: "Allows modification of confirmed bookings before departure", + }) + @ApiResponse({ status: 200, description: "Booking modified successfully" }) + @ApiResponse({ + status: 400, + description: "Cannot modify cancelled or past bookings", }) - @ApiResponse({ status: 200, description: 'Booking modified successfully' }) - @ApiResponse({ status: 400, description: 'Cannot modify cancelled or past bookings' }) modify(@Body() dto: ModifyBookingDto) { return this.service.modify(dto); } - - @Delete(':id') + + @Delete(":id") @PassengerAdmin() - @ApiBearerAuth('IAM-auth') + @ApiBearerAuth("IAM-auth") @ApiOperation({ - description: 'Permanently deletes a booking record' + description: "Permanently deletes a booking record", }) - @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) - @ApiResponse({ status: 200, description: 'Booking deleted successfully' }) - @ApiResponse({ status: 404, description: 'Booking not found' }) - delete(@Param('id') id: string, @Query('cascade') cascade?: string) { - return this.service.delete(id, cascade === 'true'); + @ApiQuery({ + name: "cascade", + required: false, + type: Boolean, + description: "Force delete with all related data", + }) + @ApiResponse({ status: 200, description: "Booking deleted successfully" }) + @ApiResponse({ status: 404, description: "Booking not found" }) + delete(@Param("id") id: string, @Query("cascade") cascade?: string) { + return this.service.delete(id, cascade === "true"); } - @Patch(':id') - @SetMetadata('isPublic', true) + @Patch(":id") + @SetMetadata("isPublic", true) @ApiOperation({ - description: 'Updates booking information for admin/agent operations' + 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); + @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); } - @Delete(':bookingRef') + @Delete(":bookingRef") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Cancel booking with refund', - description: 'Cancels booking and processes refund (80% for confirmed bookings)' + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Cancel booking with refund", + description: + "Cancels booking and processes refund (80% for confirmed bookings)", }) - @ApiResponse({ status: 200, description: 'Booking cancelled with refund amount' }) - @ApiResponse({ status: 400, description: 'Booking already cancelled' }) - cancel(@Param('bookingRef') ref: string, @Body() dto: CancelBookingDto) { - return this.service.cancel(ref, dto.reason); + @ApiResponse({ + status: 200, + description: "Booking cancelled with refund amount", + }) + @ApiResponse({ status: 400, description: "Booking already cancelled" }) + cancel(@Param("bookingRef") ref: string, @Body() dto: CancelBookingDto) { + return this.service.cancel(ref, dto.reason); } } 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 cfa4a8805..502d81a73 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,17 +1,42 @@ -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 { 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'; -import { PassengerAdmin } from '../../common/passenger-guards'; -import { VerifaydaService } from '../verifayda/verifayda.service'; -import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; -import { PrismaService } from '../../common/prisma.service'; +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 { 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"; +import { PassengerAdmin } from "../../common/passenger-guards"; +import { VerifaydaService } from "../verifayda/verifayda.service"; +import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard"; +import { PrismaService } from "../../common/prisma.service"; -@ApiTags('Passengers') -@Controller('passengers') -@Throttle({ strict: { limit: 20, ttl: 60_000 } }) +@ApiTags("Passengers") +@Controller("passengers") +// @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PassengersController { constructor( private service: PassengersService, @@ -20,9 +45,9 @@ export class PassengersController { ) {} @Get() - @SetMetadata('isPublic', true) - @ApiOperation({ - summary: 'List all travelers with filters (Admin/Agent)', + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: "List all travelers with filters (Admin/Agent)", description: `**Returns paginated list of all travelers in the system** --- @@ -54,90 +79,114 @@ export class PassengersController { - **faydaVerified**: Whether verified via Verifayda - **loyaltyTier/loyaltyPoints**: If linked to user account - **totalBookings**: Number of bookings -- **createdAt**: When traveler was first added to system` +- **createdAt**: When traveler was first added to system`, + }) + @ApiQuery({ + name: "search", + required: false, + description: "Search by name, email, or phone", + }) + @ApiQuery({ + name: "gender", + required: false, + description: "Filter by gender (Male, Female, Other)", + }) + @ApiQuery({ + name: "dateFrom", + required: false, + description: "Filter by creation date from (YYYY-MM-DD)", + }) + @ApiQuery({ + name: "dateTo", + required: false, + description: "Filter by creation date to (YYYY-MM-DD)", + }) + @ApiQuery({ + name: "page", + required: false, + description: "Page number (default: 1)", + }) + @ApiQuery({ + name: "pageSize", + required: false, + description: "Items per page (default: 20)", }) - @ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' }) - @ApiQuery({ name: 'gender', required: false, description: 'Filter by gender (Male, Female, Other)' }) - @ApiQuery({ name: 'dateFrom', required: false, description: 'Filter by creation date from (YYYY-MM-DD)' }) - @ApiQuery({ name: 'dateTo', required: false, description: 'Filter by creation date to (YYYY-MM-DD)' }) - @ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' }) - @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page (default: 20)' }) @ApiResponse({ status: 200, - description: 'Travelers retrieved successfully', + description: "Travelers retrieved successfully", schema: { example: { items: [ { - id: 'uuid-123', - fullName: 'Abebe Kebede', - email: 'abebe@example.com', - phone: '+251911234567', - gender: 'Male', - dateOfBirth: '1985-03-15', - nationality: 'Ethiopian', + id: "uuid-123", + fullName: "Abebe Kebede", + email: "abebe@example.com", + phone: "+251911234567", + gender: "Male", + dateOfBirth: "1985-03-15", + nationality: "Ethiopian", faydaVerified: true, - loyaltyTier: 'SILVER', + loyaltyTier: "SILVER", loyaltyPoints: 1500, totalBookings: 5, - createdAt: '2024-01-10T12:00:00.000Z' - } + createdAt: "2024-01-10T12:00:00.000Z", + }, ], meta: { page: 1, pageSize: 20, total: 150, - totalPages: 8 - } - } - } + totalPages: 8, + }, + }, + }, }) @ApiResponse({ status: 200, - description: 'Travelers retrieved successfully', + description: "Travelers retrieved successfully", schema: { example: { items: [ { - id: 'uuid-123', - fullName: 'Abebe Kebede', - email: 'abebe@example.com', - phone: '+251911234567', - gender: 'Male', - dateOfBirth: '1985-03-15', - nationality: 'Ethiopian', - nationalityCode: 'ET', + id: "uuid-123", + fullName: "Abebe Kebede", + email: "abebe@example.com", + phone: "+251911234567", + gender: "Male", + dateOfBirth: "1985-03-15", + nationality: "Ethiopian", + nationalityCode: "ET", faydaVerified: true, - faydaVerifiedAt: '2024-01-15T10:30:00.000Z', + faydaVerifiedAt: "2024-01-15T10:30:00.000Z", passportNumber: null, passportCountry: null, passportExpiryDate: null, - idDocumentType: 'NATIONAL_ID', + idDocumentType: "NATIONAL_ID", verified: true, - lastLoginAt: '2024-01-20T08:15:00.000Z', - role: 'PASSENGER', + lastLoginAt: "2024-01-20T08:15:00.000Z", + role: "PASSENGER", loyalty: { - tier: 'SILVER', + tier: "SILVER", pointsBalance: 1500, - lifetimePoints: 3000 + lifetimePoints: 3000, }, wallet: { balanceMinor: 50000, - currency: 'ETB' + currency: "ETB", }, - loyaltyTier: 'SILVER', + loyaltyTier: "SILVER", loyaltyPoints: 1500, totalBookings: 5, - createdAt: '2024-01-10T12:00:00.000Z' + createdAt: "2024-01-10T12:00:00.000Z", }, { - id: 'uuid-456', - fullName: 'Sara Ketsela', + id: "uuid-456", + fullName: "Sara Ketsela", email: null, phone: null, - gender: 'Female', - dateOfBirth: '1990-08-22', - nationality: 'Ethiopian', + gender: "Female", + dateOfBirth: "1990-08-22", + nationality: "Ethiopian", nationalityCode: null, faydaVerified: false, faydaVerifiedAt: null, @@ -150,54 +199,59 @@ export class PassengersController { role: null, loyalty: null, wallet: null, - loyaltyTier: 'BRONZE', + loyaltyTier: "BRONZE", loyaltyPoints: 0, totalBookings: 1, - createdAt: '2024-01-18T14:30:00.000Z' - } + createdAt: "2024-01-18T14:30:00.000Z", + }, ], meta: { page: 1, pageSize: 20, total: 150, - totalPages: 8 - } - } - } + totalPages: 8, + }, + }, + }, }) findAll( - @Query('search') search?: string, - @Query('gender') gender?: string, - @Query('dateFrom') dateFrom?: string, - @Query('dateTo') dateTo?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, + @Query("search") search?: string, + @Query("gender") gender?: string, + @Query("dateFrom") dateFrom?: string, + @Query("dateTo") dateTo?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, ) { - return this.service.findAll({ - search, + return this.service.findAll({ + search, gender, dateFrom, dateTo, - page: page ? parseInt(page) : 1, - pageSize: pageSize ? parseInt(pageSize) : 20 + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20, }); } - @Get('me') + @Get("me") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Get current passenger profile', - description: 'Returns complete profile for authenticated passenger including passport details and verification status. Returns null if no passenger profile exists.' + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Get current passenger profile", + description: + "Returns complete profile for authenticated passenger including passport details and verification status. Returns null if no passenger profile exists.", }) - @ApiResponse({ - status: 200, - description: 'Passenger profile retrieved successfully or null if not found' + @ApiResponse({ + status: 200, + description: + "Passenger profile retrieved successfully or null if not found", + }) + @ApiResponse({ + status: 401, + description: "Unauthorized - Invalid or missing token", }) - @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) async getMe(@Request() req: any) { if (!req.user || !req.user.id) { - throw new UnauthorizedException('User not authenticated'); + throw new UnauthorizedException("User not authenticated"); } try { @@ -211,26 +265,26 @@ export class PassengersController { } } - @Get(':id/profile') + @Get(":id/profile") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get passenger profile' }) - getProfile(@Param('id') id: string) { + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get passenger profile" }) + getProfile(@Param("id") id: string) { return this.service.getProfile(id); } - @Get(':id/stats') + @Get(":id/stats") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get passenger stats' }) - getStats(@Param('id') id: string) { + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get passenger stats" }) + getStats(@Param("id") id: string) { return this.service.getStats(id); } - @Post('verify-fayda') - @SetMetadata('isPublic', true) + @Post("verify-fayda") + @SetMetadata("isPublic", true) @ApiOperation({ - summary: 'Verify Ethiopian national ID via Verifayda 2.0', + summary: "Verify Ethiopian national ID via Verifayda 2.0", description: `**Standalone endpoint for pre-verification of Ethiopian national IDs** --- @@ -277,32 +331,35 @@ Pre-verify national ID to auto-fill passenger registration form before submissio - **Public endpoint** (no authentication required) - Can be called before login/registration`, }) - @ApiResponse({ - status: 200, - description: 'Verification successful with passenger data', + @ApiResponse({ + status: 200, + description: "Verification successful with passenger data", schema: { example: { verified: true, passengerData: { - fullName: 'Abebe Kebede', - dateOfBirth: '1985-03-15T00:00:00.000Z', - gender: 'Male', - nationality: 'Ethiopian' - } - } - } + fullName: "Abebe Kebede", + dateOfBirth: "1985-03-15T00:00:00.000Z", + gender: "Male", + nationality: "Ethiopian", + }, + }, + }, + }) + @ApiResponse({ + status: 400, + description: "Verification failed or Verifayda disabled", }) - @ApiResponse({ status: 400, description: 'Verification failed or Verifayda disabled' }) verifyFayda(@Body() dto: VerifyFaydaDto) { return this.verifaydaService.verifyNationalId(dto.nationalId); } - @Post('register') - @SetMetadata('isPublic', true) + @Post("register") + @SetMetadata("isPublic", true) @UseGuards(OptionalJwtGuard) - @ApiBearerAuth('JWT-auth') + @ApiBearerAuth("JWT-auth") @ApiOperation({ - summary: 'Universal passenger registration endpoint', + summary: "Universal passenger registration endpoint", description: `**Single endpoint for all passenger registration scenarios** --- @@ -357,45 +414,45 @@ The API automatically detects: ### Replaces - Manual verification + save flows`, }) - @ApiResponse({ - status: 201, - description: 'Passenger registered successfully', + @ApiResponse({ + status: 201, + description: "Passenger registered successfully", schema: { example: { - id: 'uuid-123', - passengerName: 'Abebe Kebede', - dateOfBirth: '1985-03-15T00:00:00.000Z', - nationality: 'Ethiopian', + id: "uuid-123", + passengerName: "Abebe Kebede", + dateOfBirth: "1985-03-15T00:00:00.000Z", + nationality: "Ethiopian", verified: true, linked: false, - message: 'Passenger details saved for guest booking' - } - } + message: "Passenger details saved for guest booking", + }, + }, }) - @ApiResponse({ - status: 400, - description: 'Validation error or verification failed', + @ApiResponse({ + status: 400, + description: "Validation error or verification failed", schema: { example: { statusCode: 400, - message: 'Validation failed', - error: 'Bad Request' - } - } + message: "Validation failed", + error: "Bad Request", + }, + }, }) - @ApiResponse({ - status: 401, - description: 'Invalid JWT token (only if token provided but invalid)' + @ApiResponse({ + status: 401, + description: "Invalid JWT token (only if token provided but invalid)", }) registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) { const userId = req.user?.id; return this.service.registerPassenger({ ...dto, userId }); } - @Post('save-details') - @SetMetadata('isPublic', true) + @Post("save-details") + @SetMetadata("isPublic", true) @ApiOperation({ - summary: 'Bulk save passenger details from booking flow', + summary: "Bulk save passenger details from booking flow", description: `**Endpoint for saving multiple passengers in a single booking** --- @@ -427,105 +484,117 @@ Save all passenger details for a multi-passenger booking before proceeding to se ### Response Returns saved passenger details with generated IDs and confirmation.`, }) - @ApiResponse({ - status: 201, - description: 'All passenger details saved successfully', + @ApiResponse({ + status: 201, + description: "All passenger details saved successfully", schema: { example: { count: 2, - passengerIds: ['uuid-1', 'uuid-2'], + passengerIds: ["uuid-1", "uuid-2"], passengers: [ { - id: 'uuid-1', - passengerName: 'Abebe Kebede', - dateOfBirth: '1985-03-15T00:00:00.000Z', - nationality: 'Ethiopian', - nationalId: 'ET123456789' + id: "uuid-1", + passengerName: "Abebe Kebede", + dateOfBirth: "1985-03-15T00:00:00.000Z", + nationality: "Ethiopian", + nationalId: "ET123456789", }, { - id: 'uuid-2', - passengerName: 'Sara Ketsela', - dateOfBirth: '1990-08-22T00:00:00.000Z', - nationality: 'Ethiopian', - nationalId: 'ET987654321' - } + id: "uuid-2", + passengerName: "Sara Ketsela", + dateOfBirth: "1990-08-22T00:00:00.000Z", + nationality: "Ethiopian", + nationalId: "ET987654321", + }, ], - message: 'Passenger details saved successfully' - } - } + message: "Passenger details saved successfully", + }, + }, + }) + @ApiResponse({ + status: 400, + description: "Validation error - passengers array required", }) - @ApiResponse({ status: 400, description: 'Validation error - passengers array required' }) savePassengers(@Body() dto: SavePassengersDto) { - return this.service.savePassengers(dto.passengers, dto.userId, dto.deviceId); + return this.service.savePassengers( + dto.passengers, + dto.userId, + dto.deviceId, + ); } - @Post('traveler-profiles') + @Post("traveler-profiles") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Add traveler profile (family member)' }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Add traveler profile (family member)" }) createTravelerProfile(@Body() dto: CreateTravelerProfileDto) { return this.service.createTravelerProfile(dto); } - @Get(':id/traveler-profiles') + @Get(":id/traveler-profiles") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get traveler profiles for passenger' }) - getTravelerProfiles(@Param('id') id: string) { + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get traveler profiles for passenger" }) + getTravelerProfiles(@Param("id") id: string) { return this.service.getTravelerProfiles(id); } - @Post('saved-routes') + @Post("saved-routes") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Save a route' }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Save a route" }) createSavedRoute(@Body() dto: CreateSavedRouteDto) { return this.service.createSavedRoute(dto); } - @Get(':id/saved-routes') + @Get(":id/saved-routes") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get saved routes' }) - getSavedRoutes(@Param('id') id: string) { + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get saved routes" }) + getSavedRoutes(@Param("id") id: string) { return this.service.getSavedRoutes(id); } - @Patch(':id') - @SetMetadata('isPublic', true) - @ApiOperation({ - summary: 'Update passenger details', - description: 'Updates passenger information for admin/agent operations' + @Patch(":id") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: "Update passenger details", + description: "Updates passenger information for admin/agent operations", }) - @ApiResponse({ status: 200, description: 'Passenger updated successfully' }) - @ApiResponse({ status: 404, description: 'Passenger not found' }) - updatePassenger(@Param('id') id: string, @Body() dto: any) { + @ApiResponse({ status: 200, description: "Passenger updated successfully" }) + @ApiResponse({ status: 404, description: "Passenger not found" }) + updatePassenger(@Param("id") id: string, @Body() dto: any) { return this.service.updatePassenger(id, dto); } - @Delete(':id') + @Delete(":id") @PassengerAdmin() - @ApiBearerAuth('IAM-auth') - @ApiOperation({ - summary: 'Delete passenger (admin only)', - description: 'Permanently deletes a passenger record and associated data' + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Delete passenger (admin only)", + description: "Permanently deletes a passenger record and associated data", }) - @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related bookings and data' }) - @ApiResponse({ status: 200, description: 'Passenger deleted successfully' }) - @ApiResponse({ status: 404, description: 'Passenger not found' }) - deletePassenger(@Param('id') id: string, @Query('cascade') cascade?: string) { - return this.service.deletePassenger(id, cascade === 'true'); + @ApiQuery({ + name: "cascade", + required: false, + type: Boolean, + description: "Force delete with all related bookings and data", + }) + @ApiResponse({ status: 200, description: "Passenger deleted successfully" }) + @ApiResponse({ status: 404, description: "Passenger not found" }) + deletePassenger(@Param("id") id: string, @Query("cascade") cascade?: string) { + return this.service.deletePassenger(id, cascade === "true"); } - @Get(':id/usage') - @SetMetadata('isPublic', true) - @ApiOperation({ - summary: 'Check if passenger is in use', - description: 'Returns list of modules/data that reference this passenger' + @Get(":id/usage") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: "Check if passenger is in use", + description: "Returns list of modules/data that reference this passenger", }) - @ApiResponse({ status: 200, description: 'Usage information retrieved' }) - @ApiResponse({ status: 404, description: 'Passenger not found' }) - checkUsage(@Param('id') id: string) { + @ApiResponse({ status: 200, description: "Usage information retrieved" }) + @ApiResponse({ status: 404, description: "Passenger not found" }) + checkUsage(@Param("id") id: string) { return this.service.checkPassengerUsage(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 58be16c05..918c0b8b8 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -41,7 +41,7 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @ApiTags("Payment") @Controller("payments") -@Throttle({ strict: { limit: 20, ttl: 60_000 } }) +// @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PaymentsController { constructor(private service: PaymentsService) {} @@ -79,7 +79,7 @@ export class PaymentsController { } @Post("initiate") - @SetMetadata('isPublic', true) + @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`, @@ -89,14 +89,14 @@ export class PaymentsController { } @Get("intents/:bookingId") - @SetMetadata('isPublic', true) + @SetMetadata("isPublic", true) @ApiOperation({ summary: "Get payment intent status for a booking" }) getIntent(@Param("bookingId") bookingId: string) { return this.service.getIntentByBookingId(bookingId); } @Post(":bookingId/confirm") - @SetMetadata('isPublic', true) + @SetMetadata("isPublic", true) @ApiOperation({ summary: "Confirm an OTP-debit payment (CAC Bank)", description: @@ -111,7 +111,7 @@ export class PaymentsController { } @Get("waafi/return") - @SetMetadata('isPublic', true) + @SetMetadata("isPublic", true) @ApiOperation({ summary: "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " + @@ -142,7 +142,10 @@ export class PaymentsController { } @Post(":bookingId/force-confirm") - @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin]) + @PassengerStaff([ + PASSENGER_PERMS.payments.manageMethods, + PASSENGER_PERMS.admin, + ]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Force-confirm payment & generate ticket (back-office only)", @@ -150,12 +153,18 @@ export class PaymentsController { "Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " + "Use when a vendor payment completed but the webhook was never delivered. Idempotent.", }) - forceConfirm(@Param("bookingId") bookingId: string, @Body() dto: ForceConfirmDto) { + forceConfirm( + @Param("bookingId") bookingId: string, + @Body() dto: ForceConfirmDto, + ) { return this.service.forceConfirmPayment(bookingId, dto); } @Post("methods") - @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin]) + @PassengerStaff([ + PASSENGER_PERMS.payments.manageMethods, + PASSENGER_PERMS.admin, + ]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Add a payment system to the platform catalog (admin only)", @@ -165,17 +174,23 @@ export class PaymentsController { } @Patch("methods/:id") - @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin]) + @PassengerStaff([ + PASSENGER_PERMS.payments.manageMethods, + PASSENGER_PERMS.admin, + ]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Update a payment method configuration (admin only)", }) - updateMethod(@Param("id") id: string, @Body() dto: Partial) { + updateMethod( + @Param("id") id: string, + @Body() dto: Partial, + ) { return this.service.updatePaymentMethod(id, dto); } @Get("methods") - @SetMetadata('isPublic', true) + @SetMetadata("isPublic", true) @ApiOperation({ summary: "List payment systems supported by the platform", description: @@ -183,14 +198,12 @@ export class PaymentsController { }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) - getMethods( - @Query("region") region?: PaymentRegionEnum, - ) { + getMethods(@Query("region") region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); } @Get("booking-amount") - @SetMetadata('isPublic', true) + @SetMetadata("isPublic", true) @ApiOperation({ summary: "Get booking amount in a specific currency", description: @@ -199,7 +212,12 @@ export class PaymentsController { "Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).", }) @ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" }) - @ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" }) + @ApiQuery({ + name: "currency", + required: true, + example: "DJF", + description: "Target currency: ETB, DJF, or USD", + }) @ApiOkResponse({ type: BookingAmountResponseDto }) getBookingAmount( @Query("bookingId") bookingId: string, @@ -209,7 +227,7 @@ export class PaymentsController { } @Get("checkout") - @SetMetadata('isPublic', true) + @SetMetadata("isPublic", true) @ApiOperation({ summary: "Browser checkout redirect", description: diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts index 3cfa1beb8..040d51f10 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -1,28 +1,29 @@ -import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../common/prisma.service'; +import { Injectable } from "@nestjs/common"; +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', - BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure', - THROTTLE_AUTH_LIMIT: 'throttle_auth_limit', - THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms', - THROTTLE_STRICT_LIMIT: 'throttle_strict_limit', - THROTTLE_STRICT_TTL_MS: 'throttle_strict_ttl_ms', - THROTTLE_DEFAULT_LIMIT: 'throttle_default_limit', - THROTTLE_DEFAULT_TTL_MS: 'throttle_default_ttl_ms', + SEAT_HOLD_DURATION_MINUTES: "seat_hold_duration_minutes", + HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: "hold_cutoff_hours_before_departure", + BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: + "boarding_window_hours_before_departure", + THROTTLE_AUTH_LIMIT: "throttle_auth_limit", + THROTTLE_AUTH_TTL_MS: "throttle_auth_ttl_ms", + THROTTLE_STRICT_LIMIT: "throttle_strict_limit", + THROTTLE_STRICT_TTL_MS: "throttle_strict_ttl_ms", + THROTTLE_DEFAULT_LIMIT: "throttle_default_limit", + THROTTLE_DEFAULT_TTL_MS: "throttle_default_ttl_ms", } as const; const DEFAULTS: Record = { - [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', - [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', - [CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4', - [CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5', - [CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000', - [CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20', - [CONFIG_KEYS.THROTTLE_STRICT_TTL_MS]: '60000', - [CONFIG_KEYS.THROTTLE_DEFAULT_LIMIT]: '100', - [CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS]: '60000', + [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: "5", + [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: "2", + [CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: "4", + [CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: "5000", + [CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: "60", + [CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: "20000", + [CONFIG_KEYS.THROTTLE_STRICT_TTL_MS]: "60", + [CONFIG_KEYS.THROTTLE_DEFAULT_LIMIT]: "100000", + [CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS]: "6", }; @Injectable() @@ -38,11 +39,14 @@ export class SystemConfigService { async getValue(key: string): Promise { const row = await this.prisma.systemConfig.findUnique({ where: { key } }); - return row?.value ?? DEFAULTS[key] ?? ''; + return row?.value ?? DEFAULTS[key] ?? ""; } async getNumber(key: string): Promise { - return parseInt(await this.getValue(key), 10) || parseInt(DEFAULTS[key] ?? '0', 10); + return ( + parseInt(await this.getValue(key), 10) || + parseInt(DEFAULTS[key] ?? "0", 10) + ); } async set(key: string, value: string): Promise { @@ -53,7 +57,9 @@ export class SystemConfigService { }); } - async updateMany(entries: Record): Promise> { + async updateMany( + entries: Record, + ): Promise> { await Promise.all(Object.entries(entries).map(([k, v]) => this.set(k, v))); return this.getAll(); } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts index 9ba4cd515..878dc6b36 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -8,25 +8,25 @@ import { Query, Req, UseGuards, -} from '@nestjs/common'; +} from "@nestjs/common"; import { ApiBearerAuth, ApiOkResponse, 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 { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; -import { JwtGuard } from '../../common/jwt.guard'; -import { OptionalJwtGuard } from './optional-jwt.guard'; +} from "@nestjs/swagger"; +import { Throttle } from "@nestjs/throttler"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; +import { JwtGuard } from "../../common/jwt.guard"; +import { OptionalJwtGuard } from "./optional-jwt.guard"; import { CompleteVerificationResultDto, StartVerificationDto, VerifaydaCallbackDto, VerificationStatusDto, -} from './verifayda.dto'; -import { VerifaydaService } from './verifayda.service'; +} from "./verifayda.dto"; +import { VerifaydaService } from "./verifayda.service"; /** Minimal slices of the Express req we touch (avoids a hard dependency on * `@types/express`, which isn't resolved in this package). */ @@ -37,19 +37,19 @@ interface RequestWithUser { user: TCurrentUser; } -@ApiTags('Fayda Verification') -@Controller('fayda/verification') -@Throttle({ auth: { limit: 5, ttl: 60_000 } }) +@ApiTags("Fayda Verification") +@Controller("fayda/verification") +// @Throttle({ auth: { limit: 5, ttl: 60_000 } }) export class VerifaydaController { constructor(private readonly service: VerifaydaService) {} - @Post('start') + @Post("start") @IsPublic() @HttpCode(HttpStatus.OK) @UseGuards(OptionalJwtGuard) - @ApiBearerAuth('JWT-auth') + @ApiBearerAuth("JWT-auth") @ApiOperation({ - summary: 'Start a VeriFayda 2.0 verification session', + 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. @@ -58,11 +58,11 @@ export class VerifaydaController { - 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({ - description: 'Authorize URL the frontend should redirect the user to.', + description: "Authorize URL the frontend should redirect the user to.", schema: { example: { authorizationUrl: - 'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...', + "https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...", }, }, }) @@ -71,18 +71,19 @@ export class VerifaydaController { @Req() req: RequestWithOptionalUser, ): Promise<{ authorizationUrl: string }> { const authorizationUrl = await this.service.startVerification({ - purpose: dto.purpose ?? 'VERIFY', - platform: dto.platform ?? 'WEB', + purpose: dto.purpose ?? "VERIFY", + platform: dto.platform ?? "WEB", userId: req.user?.id, wantsPasswordSetup: dto.wantsPasswordSetup ?? false, }); return { authorizationUrl }; } - @Get('complete') + @Get("complete") @IsPublic() @ApiOperation({ - summary: 'Complete a verification (Fayda redirect / client callback lands here)', + summary: + "Complete a verification (Fayda redirect / client callback lands here)", description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``, }) @ApiOkResponse({ type: CompleteVerificationResultDto }) @@ -92,18 +93,16 @@ export class VerifaydaController { return this.service.completeVerification(dto); } - @Get('status') + @Get("status") @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "Get the current user's Fayda verification status", description: - 'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.', + "Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.", }) @ApiOkResponse({ type: VerificationStatusDto }) - async status( - @Req() req: RequestWithUser, - ): Promise { + async status(@Req() req: RequestWithUser): Promise { return this.service.getVerificationStatus(req.user.id); } } diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts index 8b0c5ed2e..40b6ef91f 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts @@ -1,18 +1,47 @@ -import { Body, Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } 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'; +import { + Body, + Controller, + Get, + Param, + Post, + Delete, + UseGuards, + SetMetadata, + Query, +} 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"; -@ApiTags('Wallet') -@Controller('wallet') +@ApiTags("Wallet") +@Controller("wallet") @UseGuards(JwtGuard) -@ApiBearerAuth('JWT-auth') -@Throttle({ strict: { limit: 20, ttl: 60_000 } }) +@ApiBearerAuth("JWT-auth") +// @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class WalletController { constructor(private service: WalletService) {} - @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } - @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } - @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } - @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete wallet account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } + @Get("accounts") + @SetMetadata("isPublic", true) + @ApiOperation({ summary: "List all wallet accounts" }) + getAccounts(@Query() q: any) { + return this.service.getAccounts(q); + } + @Get(":passengerId") + @ApiOperation({ summary: "Get wallet balance and ledger" }) + getWallet(@Param("passengerId") id: string) { + return this.service.getWallet(id); + } + @Post(":passengerId/topup") @ApiOperation({ summary: "Top up wallet" }) topUp( + @Param("passengerId") id: string, + @Body("amountMinor") amount: number, + ) { + return this.service.topUp(id, amount); + } + @Delete("accounts/:id") + @SetMetadata("isPublic", true) + @ApiOperation({ summary: "Delete wallet account" }) + deleteAccount(@Param("id") id: string) { + return this.service.deleteAccount(id); + } } 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 c7efd7b9b..7dccc7bdf 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 @@ -1166,6 +1166,14 @@ function PassengersForm() { Finish verifying Passenger {(verifyingIndex ?? 0) + 1} first

)} + {/* */} ) : showManualEntryLink ? (
diff --git a/scripts/deploy/sync-env-from-server-jenkins.sh b/scripts/deploy/sync-env-from-server-jenkins.sh index 74b9a3f13..e444996d6 100644 --- a/scripts/deploy/sync-env-from-server-jenkins.sh +++ b/scripts/deploy/sync-env-from-server-jenkins.sh @@ -12,24 +12,23 @@ # Server layout (one file per service): # /home/user/environmen///freight-api.env # /home/user/environmen///freight-portal.env - set -euo pipefail - DEPLOY_USER="${DEPLOY_USER:-tria}" BRANCH="${BRANCH:?BRANCH is required}" BRANCH_SLUG="${BRANCH_SLUG:-$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")}" ENV_ROOT="${ENV_ROOT:-/home/${DEPLOY_USER}/environment/edr/${BRANCH_SLUG}/${PROJECT:?PROJECT is required}}" CI_ENV_FILE="${CI_ENV_FILE:?CI_ENV_FILE is required (e.g. \${WORKSPACE}/.ci-env/.env)}" - if [[ ! -d "${ENV_ROOT}" ]]; then echo "Environment directory not found: ${ENV_ROOT}" >&2 exit 1 fi echo "Using environment directory: ${ENV_ROOT}" - mkdir -p "$(dirname "${CI_ENV_FILE}")" +if [[ -d "${CI_ENV_FILE}" ]]; then + echo "Removing stale directory at ${CI_ENV_FILE}" >&2 + rm -rf "${CI_ENV_FILE}" +fi : > "${CI_ENV_FILE}" - declare -A SERVICE_ENV_TARGET=( ["freight-api"]="apps/edr-freight-api/.env" ["freight-portal"]="apps/edr-freight-web/portal/.env" @@ -39,36 +38,29 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment-api"]="apps/edr-payment-api/.env" ) - for service in "$@"; do src="${ENV_ROOT}/${service}.env" dest="${SERVICE_ENV_TARGET[${service}]:-}" - if [[ -z "${dest}" ]]; then echo "Unknown service: ${service}" >&2 exit 1 fi - if [[ ! -f "${src}" ]]; then echo "Missing env file: ${src}" >&2 exit 1 fi - mkdir -p "$(dirname "${dest}")" cp "${src}" "${dest}" echo "Synced ${src} -> ${dest}" - port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${src}" | head -n1 | tr -d '[:space:]') if [[ -z "${port_value}" ]]; then echo "Missing required PORT in env file: ${src}" >&2 exit 1 fi - service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_') echo "${service_var}_PORT=${port_value}" >> "${CI_ENV_FILE}" echo "Exported ${service_var}_PORT from ${src}" - # Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args. grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \ | sed -E 's/^[[:space:]]*//' >> "${CI_ENV_FILE}" || true -done \ No newline at end of file +done