From ed30ab0cdab375f1a900564811053c217cbb7f3e Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 2 Jun 2026 14:51:12 +0300 Subject: [PATCH] Updated the passenger web portal and added more endpoints to the api --- README.md | 70 +- apps/edr-passenger-api/src/main.ts | 53 +- .../src/modules/auth/auth.controller.ts | 143 ++- .../src/modules/auth/auth.service.ts | 66 ++ .../src/modules/bookings/bookings.dto.ts | 2 +- .../src/modules/bookings/guest-booking.dto.ts | 2 +- .../modules/bookings/guest-booking.service.ts | 60 +- .../passengers/passengers.controller.ts | 200 ++++- .../src/modules/passengers/passengers.dto.ts | 193 ++++- .../modules/passengers/passengers.service.ts | 161 +++- .../modules/schedules/schedules.service.ts | 5 +- .../src/modules/support/support.service.ts | 2 +- .../backoffice/src/app/routes/page.tsx | 294 +++++-- .../backoffice/src/app/schedules/page.tsx | 32 +- .../backoffice/src/app/seats/page.tsx | 331 ++++--- .../src/components/ui/DataTable.tsx | 157 ++-- .../backoffice/src/lib/api/index.ts | 4 + .../src/app/booking/confirmation/page.tsx | 80 +- .../src/app/booking/passengers/page.tsx | 530 +++++++++--- .../portal/src/app/booking/payment/page.tsx | 86 +- .../portal/src/app/booking/results/page.tsx | 5 +- .../portal/src/app/booking/review/page.tsx | 201 +++-- .../portal/src/app/booking/search/page.tsx | 6 +- .../portal/src/app/booking/seats/page.tsx | 96 +- .../portal/src/app/guide/page.tsx | 245 ++++++ .../portal/src/app/login/page.tsx | 23 +- .../portal/src/app/profile/page.tsx | 818 ++++++++++++++++++ .../portal/src/components/AppHeader.tsx | 40 +- .../src/components/ModernDatePicker.tsx | 16 +- .../src/components/ProgressIndicator.tsx | 48 +- .../portal/src/components/ThemeToggle.tsx | 5 +- .../portal/src/lib/api-client.ts | 6 +- .../portal/src/lib/auth-store.ts | 84 +- .../portal/src/lib/booking-store.ts | 6 + 34 files changed, 3396 insertions(+), 674 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/guide/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/profile/page.tsx diff --git a/README.md b/README.md index c35b3cfb2..46a0fa324 100644 --- a/README.md +++ b/README.md @@ -314,9 +314,34 @@ Content-Type: application/json } ``` -#### 4. Register International Passenger +#### 4. Universal Passenger Registration (NEW) ```bash -POST /passengers/register-international +# Guest Ethiopian with Fayda verification +POST /passengers/register +Content-Type: application/json + +{ + "passengerName": "Abebe Kebede", + "dateOfBirth": "1985-03-15", + "nationalId": "ET123456789", + "phone": "+251911234567", + "deviceId": "device-uuid-123" +} + +# Logged-in user with JWT token +POST /passengers/register +Authorization: Bearer +Content-Type: application/json + +{ + "passengerName": "Abebe Kebede", + "dateOfBirth": "1985-03-15", + "nationalId": "ET123456789", + "phone": "+251911234567" +} + +# International passenger (passport) +POST /passengers/register Content-Type: application/json { @@ -326,11 +351,42 @@ Content-Type: application/json "passportCountry": "Kenya", "nationality": "Kenyan", "phone": "+254712345678", - "email": "john@example.com" + "email": "john@example.com", + "deviceId": "device-uuid-123" } ``` -#### 5. Search Trips +#### 5. Get User Profile (NEW) +```bash +GET /auth/profile +Authorization: Bearer + +# Response includes user, passenger, loyalty, and wallet details +{ + "id": "uuid", + "email": "user@example.com", + "phone": "+251911234567", + "fullName": "John Doe", + "role": "PASSENGER", + "nationality": "Ethiopian", + "faydaVerified": true, + "faydaVerifiedAt": "2024-01-15T10:30:00.000Z", + "passenger": { + "id": "uuid", + "loyalty": { + "tier": "SILVER", + "pointsBalance": 1500, + "lifetimePoints": 3000 + }, + "wallet": { + "balanceMinor": 50000, + "currency": "ETB" + } + } +} +``` + +#### 6. Search Trips ```bash POST /search Content-Type: application/json @@ -344,7 +400,7 @@ Content-Type: application/json } ``` -#### 6. Get Fare Quote +#### 7. Get Fare Quote ```bash POST /search/fare-quote Content-Type: application/json @@ -373,7 +429,7 @@ Content-Type: application/json } ``` -#### 7. Guest Booking (No Login Required) +#### 8. Guest Booking (No Login Required) ```bash POST /bookings/guest Content-Type: application/json @@ -398,7 +454,7 @@ Content-Type: application/json } ``` -#### 8. Agent Booking (IAM Auth) +#### 9. Agent Booking (IAM Auth) ```bash POST /agents/bookings Authorization: Bearer diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 2c471da93..35dc77441 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -210,31 +210,32 @@ Payment providers send notifications to: { type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" }, "JWT-auth", ) - .addTag("Agents", "Counter booking and management") - .addTag("Auth", "Registration and login") - .addTag("Booking", "Booking lifecycle") - .addTag("Dashboard", "Home dashboard aggregate") - .addTag("Fare Engine", "Distance-based fare calculator — km × rate × exchange rate, nationality-aware currency") - .addTag("Fleet", "Train services and coaches") - .addTag("Fraud Detection", "Fraud detection and monitoring") - .addTag("Live Tracking", "Real-time trip status and crowd signals") - .addTag("Loyalty", "Points, tiers, and rewards") - .addTag("Notifications", "Push and email notifications") - .addTag("Passenger", "Profiles, traveler profiles, saved routes") - .addTag("Payment", "Payment intents and refunds") - .addTag("Payment Webhooks", "Endpoints for payment provider notifications") - .addTag("Promotions", "Promo codes and campaigns") - .addTag("Reports", "Sales and operational reports") - .addTag("Routes", "Route information and management") - .addTag("Schedule", "Trips and fare rules") - .addTag("Search", "Trip search and fare quotes") - .addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed") - .addTag("Seats", "Seat maps and holds") - .addTag("Segment-based Seats", "Seats assigned and released by trip segments") - .addTag("Stations", "Station directory") - .addTag("Support", "FAQ and chat support") - .addTag("Tickets", "QR ticket generation and validation") - .addTag("Wallet", "Wallet balance and ledger") + .addTag("Agents", "Counter booking, shift management, and commission tracking") + .addTag("Auth", "User registration, login, and profile management") + .addTag("Booking", "Complete booking lifecycle: create, modify, cancel") + .addTag("Dashboard", "Aggregated dashboard data for home screen") + .addTag("Fare Engine", "Distance-based fare calculator with multi-currency support") + .addTag("Fayda Verification", "Ethiopian national ID verification via government API") + .addTag("Fleet", "Train services, coaches, and seat configurations") + .addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking") + .addTag("Live Tracking", "Real-time trip status, delays, and station crowds") + .addTag("Loyalty", "Points accumulation, tiers, and reward redemption") + .addTag("Notifications", "Multi-channel notifications: email, SMS, push") + .addTag("Passengers", "Passenger registration, verification, and profiles") + .addTag("Payment", "Payment processing, intents, and refunds") + .addTag("Payment Webhooks", "Payment provider webhook handlers") + .addTag("Promotions", "Promo codes, campaigns, and discount management") + .addTag("Reports", "Sales reports, occupancy analytics, and metrics") + .addTag("Routes", "Route templates with stops and fare rules") + .addTag("Schedule", "Trip schedules, availability, and status updates") + .addTag("Search", "Trip search, availability checks, and fare quotes") + .addTag("Seat Classes", "Seat class management: Economy, VIP configurations") + .addTag("Seats", "Seat maps, holds, releases, and blocking") + .addTag("Segment-based Seats", "Segment-level seat allocation and availability") + .addTag("Stations", "Station directory and information") + .addTag("Support", "FAQ management and live chat support") + .addTag("Tickets", "QR ticket generation, PDFs, and gate validation") + .addTag("Wallet", "Wallet balance, top-ups, and transaction ledger") //.addServer('http://localhost:4000', 'Development') // .addServer("https://api.edr-platform.com", "Production") .build(); @@ -246,6 +247,8 @@ Payment providers send notifications to: persistAuthorization: true, docExpansion: "none", filter: true, + tagsSorter: "alpha", + operationsSorter: "alpha", }, }); 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 6d31c8323..4d091656f 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto'; +import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Auth') @Controller('auth') @@ -78,4 +79,142 @@ export class AuthController { @ApiResponse({ status: 404, description: 'User not found' }) @ApiBody({ type: ResetPasswordDto }) resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); } + + @Post('logout') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Logout current user', + description: `Logout the authenticated user and invalidate their session. + +### What happens: +- Invalidates the current session token +- Records logout in audit log +- Frontend should clear stored token and redirect to home + +### Authentication: +- **Required**: JWT Bearer Token +- Token will be invalidated after successful logout` + }) + @ApiResponse({ + status: 200, + description: 'Logout successful', + schema: { + example: { + success: true, + message: 'Logged out successfully' + } + } + }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) + logout(@Request() req: any) { + if (!req.user || !req.user.userId) { + throw new UnauthorizedException('User not authenticated'); + } + return this.service.logout(req.user.userId); + } + + @Get('profile') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Get current user profile', + description: `**Returns complete user profile with all connected data** + +--- + +### Response Includes + +#### User Information +- Basic details (id, email, phone, fullName, role) +- Nationality and document information +- Fayda verification status +- Account timestamps (created, last login) + +#### Passenger Data (if role=PASSENGER) +- Passenger ID and preferences +- **Loyalty Account**: Tier, points balance, lifetime points +- **Wallet Account**: Balance (minor units), currency + +#### User Preferences +- Language, notification settings, etc. + +--- + +### Use Cases + +1. **App Initialization**: Fetch on app load to get user context + +2. **Profile Pre-fill**: Use data to auto-fill booking forms + +3. **Verification Check**: Check \`faydaVerified\` before registration + +4. **Loyalty Display**: Show tier and points in UI + +5. **Wallet Balance**: Display available balance + +--- + +### Authentication +- **Required**: JWT Bearer Token +- Token must be valid and not expired +- Returns profile for authenticated user only`, + }) + @ApiResponse({ + status: 200, + description: 'User profile retrieved successfully', + schema: { + example: { + id: 'user-uuid-123', + email: 'kelemu@email.com', + phone: '+251911234567', + fullName: 'Kelemu Abebe', + role: 'PASSENGER', + nationality: 'Ethiopian', + nationalityCode: 'ET', + nationalId: null, + passportNumber: null, + faydaVerified: true, + faydaVerifiedAt: '2024-01-15T10:30:00.000Z', + lastLoginAt: '2024-01-20T14:22:00.000Z', + createdAt: '2023-12-01T08:00:00.000Z', + passenger: { + id: 'passenger-uuid-456', + preferredLanguage: 'am', + loyalty: { + tier: 'SILVER', + pointsBalance: 1500, + lifetimePoints: 3000 + }, + wallet: { + balanceMinor: 50000, + currency: 'ETB' + } + }, + preferences: { + emailNotifications: true, + smsNotifications: true, + language: 'am' + } + } + } + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - Invalid or missing JWT token', + schema: { + example: { + statusCode: 401, + message: 'Unauthorized' + } + } + }) + getProfile(@Request() req: any) { + console.log('Profile request - User from JWT:', req.user); + if (!req.user || !req.user.userId) { + throw new UnauthorizedException('User not authenticated'); + } + return this.service.getProfile(req.user.userId); + } } diff --git a/apps/edr-passenger-api/src/modules/auth/auth.service.ts b/apps/edr-passenger-api/src/modules/auth/auth.service.ts index ae8018dc3..e71562f2e 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.service.ts @@ -143,4 +143,70 @@ export class AuthService { data: { userId, action, entityType, entityId, oldData, newData } }); } + + async getProfile(userId: string) { + if (!userId) { + throw new UnauthorizedException('User ID not found in token'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + passenger: { + include: { + loyalty: true, + wallet: true, + }, + }, + preferences: true, + }, + }); + + if (!user) throw new UnauthorizedException('User not found'); + + return { + id: user.id, + email: user.email, + phone: user.phone, + fullName: user.fullName, + role: user.role, + nationality: user.nationality, + nationalityCode: user.nationalityCode, + nationalId: user.nationalId, + passportNumber: user.passportNumber, + faydaVerified: user.faydaVerified, + faydaVerifiedAt: user.faydaVerifiedAt, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + passenger: user.passenger ? { + id: user.passenger.id, + preferredLanguage: user.passenger.preferredLanguage, + loyalty: user.passenger.loyalty ? { + tier: user.passenger.loyalty.tier, + pointsBalance: user.passenger.loyalty.pointsBalance, + lifetimePoints: user.passenger.loyalty.lifetimePoints, + } : null, + wallet: user.passenger.wallet ? { + balanceMinor: user.passenger.wallet.balanceMinor, + currency: user.passenger.wallet.currency, + } : null, + } : null, + preferences: user.preferences, + }; + } + + async logout(userId: string) { + // Invalidate all active sessions for this user + await this.prisma.session.deleteMany({ + where: { userId } + }); + + // Log the logout action + await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null); + + return { + success: true, + message: 'Logged out successfully' + }; + } } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index dc9989fdb..740271c1f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -10,7 +10,7 @@ export class PassengerInputDto { @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; - @ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string; + @ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string; @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string; } diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index 4c3f442d9..8fca71aea 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -22,7 +22,7 @@ export class GuestPassengerDto { @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' }) @IsOptional() @IsString() passportNumber?: string; - @ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country' }) + @ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country' }) @IsOptional() @IsString() passportCountry?: string; @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda), Djiboutian, Other' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 8c2bc69a4..1214acebc 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -71,28 +71,42 @@ export class GuestBookingService { let verifaydaData: Record | undefined; let nationality = passenger.nationality; - // Verifayda verification ONLY for Ethiopian nationals with National ID - const isEthiopian = !passenger.nationality || passenger.nationality === 'Ethiopian' || - (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !passenger.passportCountry); + // Determine if passenger is Ethiopian + const isEthiopian = passenger.nationality === 'Ethiopian' || + passenger.nationality === 'ETHIOPIAN' || + passenger.idDocumentType === IdDocumentType.NATIONAL_ID; - if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { - const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); - if (!verification.verified) { - throw new BadRequestException( - `Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}` - ); + // Ethiopian with National ID + if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { + if (passenger.idDocumentNumber) { + // Attempt Fayda verification + const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!verification.verified) { + throw new BadRequestException( + `Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}` + ); + } + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; } - passengerName = verification.passengerData?.fullName || passengerName; - verifaydaVerified = true; - verifaydaData = verification.passengerData?.profileData; nationality = 'Ethiopian'; - } else if (passenger.idDocumentType === IdDocumentType.PASSPORT) { + } + // International passenger with Passport (non-Ethiopian) + else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { + // Passport details are required for international passengers if (!passenger.passportNumber || !passenger.passportCountry) { throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); } nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); - } else if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !isEthiopian) { - // Non-Ethiopian with national ID (e.g., Djiboutian national ID) + } + // Ethiopian with Passport (manual entry without Fayda) + else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { + // Ethiopians can use passport instead of national ID + nationality = 'Ethiopian'; + } + // International with National ID (e.g., Djiboutian national ID) + else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { nationality = nationality || 'Other'; } @@ -175,11 +189,23 @@ export class GuestBookingService { createdAccount = true; } else { // Create anonymous guest passenger with minimal data + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + // Check if email exists and use a unique guest email if it does + let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`; + if (firstPassenger.email) { + const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); + if (existingUser) { + // Email exists, use guest email instead for anonymous booking + guestEmail = `guest-${uniqueId}@edr-platform.com`; + } + } + const tempUser = await this.prisma.user.create({ data: { fullName: firstPassenger.passengerName, - email: firstPassenger.email || `guest-${Date.now()}@edr-platform.com`, - phone: firstPassenger.phone || `+251${Date.now()}`, + email: guestEmail, + phone: firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`, passwordHash: await bcrypt.hash(Math.random().toString(36), 10), role: 'PASSENGER', }, 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 ba226f41f..a1a17365a 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,12 +1,13 @@ -import { Body, Controller, Get, Param, Post, UseGuards, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards, Query, Request } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { PassengersService } from './passengers.service'; -import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, RegisterInternationalPassengerDto } from './passengers.dto'; +import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; import { IamGuard } from '../../common/iam-adapter'; import { VerifaydaService } from '../verifayda/verifayda.service'; +import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; -@ApiTags('Passenger') +@ApiTags('Passengers') @Controller('passengers') export class PassengersController { constructor( @@ -56,14 +57,51 @@ export class PassengersController { @Post('verify-fayda') @ApiOperation({ summary: 'Verify Ethiopian national ID via Verifayda 2.0', - description: `Verifies Ethiopian national ID and retrieves passenger data from government database. + description: `**Standalone endpoint for pre-verification of Ethiopian national IDs** +--- + +### Purpose +Pre-verify national ID to auto-fill passenger registration form before submission. + +--- + +### Flow + +1. User enters national ID in form + +2. Frontend calls \`POST /passengers/verify-fayda\` + +3. API queries Verifayda 2.0 government database + +4. Returns verified passenger data (name, DOB, gender) + +5. Frontend auto-fills form with verified data + +6. User submits form via \`POST /passengers/register\` + +--- + +### Features - Real-time verification via Verifayda 2.0 API -- Retrieves verified passenger data (name, DOB, gender, nationality) -- National IDs NOT stored (policy compliant) +- Retrieves verified data: name, date of birth, gender, nationality +- **National IDs NOT stored** (policy compliant) - Only for Ethiopian nationals with national ID -- Non-Ethiopians should use passport (no verification required) -- Returns passenger details for booking form auto-fill`, +- Non-Ethiopians use passport (no verification) + +--- + +### Important Notes +- This is a **read-only** verification endpoint +- Does NOT save passenger data to database +- Use \`POST /passengers/register\` to actually register +- Falls back to manual entry if Verifayda disabled or fails + +--- + +### Authentication +- **Public endpoint** (no authentication required) +- Can be called before login/registration`, }) @ApiResponse({ status: 200, @@ -85,21 +123,143 @@ export class PassengersController { return this.verifaydaService.verifyNationalId(dto.nationalId); } - @Post('register-international') + @Post('register') + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Register international passenger with passport details', - description: `Saves international passenger profile for booking. + summary: 'Universal passenger registration endpoint', + description: `**Single endpoint for all passenger registration scenarios** -- For non-Ethiopian passengers (Djiboutian, Kenyan, etc.) -- Collects passport information -- No government verification required -- Profile saved for future bookings -- Can be used by logged-in users or guest users (via deviceId)`, +--- + +### Automatic Detection +The API automatically detects: +- **Passenger Type**: Ethiopian (nationalId) vs International (passportNumber) +- **Authentication**: Logged-in (JWT token) vs Guest (deviceId) +- **Verification**: Auto-attempts Fayda for Ethiopian nationals + +--- + +### Scenarios Handled + +#### 1. Guest Ethiopian Passenger +- Provide: \`nationalId\`, \`deviceId\` +- Behavior: Attempts Fayda verification → Saves to SavedPassengerProfile +- Response: \`verified: true/false\`, \`linked: false\` + +#### 2. Guest International Passenger +- Provide: \`passportNumber\`, \`passportCountry\`, \`deviceId\` +- Behavior: No verification → Saves to SavedPassengerProfile +- Response: \`verified: false\`, \`linked: false\` + +#### 3. Logged-in Ethiopian Passenger +- Provide: JWT token + \`nationalId\` +- Behavior: Attempts Fayda verification → Updates user profile +- Response: \`verified: true/false\`, \`linked: true\` + +#### 4. Logged-in International Passenger +- Provide: JWT token + \`passportNumber\`, \`passportCountry\` +- Behavior: No verification → Updates user profile +- Response: \`verified: false\`, \`linked: true\` + +--- + +### Authentication +- **Optional JWT Bearer Token** (OptionalJwtGuard) +- Token present → Links to user account +- No token → Saves as guest (requires deviceId) + +--- + +### Benefits +- Single endpoint for all scenarios +- Auto-detects passenger type and flow +- Graceful fallback if Fayda fails +- Consistent response structure + +--- + +### Replaces +- Manual verification + save flows`, }) - @ApiResponse({ status: 201, description: 'International passenger profile saved successfully' }) - @ApiResponse({ status: 400, description: 'Invalid passport details' }) - registerInternational(@Body() dto: RegisterInternationalPassengerDto) { - return this.service.registerInternational(dto); + @ApiResponse({ + status: 201, + description: 'Passenger registered successfully', + schema: { + example: { + 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' + } + } + }) + @ApiResponse({ + status: 400, + description: 'Validation error or verification failed', + schema: { + example: { + statusCode: 400, + message: 'Validation failed', + error: 'Bad Request' + } + } + }) + @ApiResponse({ + status: 401, + description: 'Invalid JWT token (only if token provided but invalid)' + }) + registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) { + const userId = req.user?.userId; + return this.service.registerPassenger({ ...dto, userId }); + } + + @Post('save-details') + @ApiOperation({ + summary: '[LEGACY] Save all passenger details before seat selection', + description: `**Note:** This endpoint is legacy. Consider using \`POST /passengers/register\` instead. + +Saves all passenger details to database before proceeding to seat selection. + +- Required step in booking flow +- Saves details for all passengers in booking +- Supports both logged-in users and guests +- Prevents data loss if user navigates away + +**Migration:** Use \`POST /passengers/register\` for new implementations.`, + }) + @ApiResponse({ + status: 201, + description: 'Passenger details saved successfully', + schema: { + example: { + count: 2, + passengerIds: ['uuid-1', 'uuid-2'], + passengers: [ + { + id: 'uuid-1', + passengerName: 'John Doe', + dateOfBirth: '1990-01-01T00:00:00.000Z', + nationality: 'Ethiopian' + }, + { + id: 'uuid-2', + passengerName: 'Jane Doe', + dateOfBirth: '1992-05-15T00:00:00.000Z', + nationality: 'Ethiopian' + } + ], + message: 'Passenger details saved successfully' + } + } + }) + @ApiResponse({ status: 400, description: 'Validation error - passengers array required' }) + savePassengers(@Body() body: any) { + const passengers = body.passengers || (Array.isArray(body) ? body : [body]); + return this.service.savePassengers(passengers, body.userId, body.deviceId); } @Post('traveler-profiles') diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts index bbdb3e5db..4a48cb8b5 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsDateString, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateTravelerProfileDto { @@ -19,7 +19,15 @@ export class CreateSavedRouteDto { } export class VerifyFaydaDto { - @ApiProperty({ example: 'ET123456789', description: 'Ethiopian national ID number' }) + @ApiProperty({ + example: 'ET123456789', + description: `**Ethiopian national ID number** + +- Format: Varies by Ethiopian ID system +- Example: ET123456789 +- Must be valid Ethiopian national ID +- Used to query Verifayda 2.0 government database` + }) @IsString() nationalId: string; } @@ -66,3 +74,184 @@ export class RegisterInternationalPassengerDto { @IsString() deviceId?: string; } + +export class SavePassengerDetailsDto { + @ApiProperty({ example: 'Abebe Kebede' }) + @IsString() + name: string; + + @ApiProperty({ example: '1985-03-15' }) + @IsDateString() + dateOfBirth: string; + + @ApiProperty({ example: 'ETHIOPIAN' }) + @IsString() + nationality: string; + + @ApiPropertyOptional({ example: 'ET123456789' }) + @IsOptional() + @IsString() + nationalId?: string; + + @ApiPropertyOptional({ example: 'P1234567' }) + @IsOptional() + @IsString() + passportNumber?: string; + + @ApiPropertyOptional({ example: 'Kenya' }) + @IsOptional() + @IsString() + passportCountry?: string; + + @ApiPropertyOptional({ example: '+251911234567' }) + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional({ example: 'email@example.com' }) + @IsOptional() + @IsString() + email?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + faydaSub?: string; +} + +export class SavePassengersDto { + @ApiProperty({ type: [SavePassengerDetailsDto] }) + passengers: SavePassengerDetailsDto[]; + + @ApiPropertyOptional({ description: 'User ID if logged in' }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ description: 'Device ID for guest users' }) + @IsOptional() + @IsString() + deviceId?: string; +} + +export class RegisterPassengerDto { + @ApiProperty({ + example: 'Abebe Kebede', + description: 'Full name of passenger (required for all scenarios)' + }) + @IsString() + passengerName: string; + + @ApiProperty({ + example: '1985-03-15', + description: 'Date of birth in ISO format YYYY-MM-DD (required for all scenarios)' + }) + @IsDateString() + dateOfBirth: string; + + @ApiPropertyOptional({ + example: 'ET123456789', + description: `**Ethiopian national ID number** + +- Triggers automatic Fayda verification if enabled +- Use for Ethiopian nationals only +- Mutually exclusive with passportNumber +- If Fayda enabled: passenger data auto-filled from government database +- If Fayda disabled: falls back to manual entry` + }) + @IsOptional() + @IsString() + nationalId?: string; + + @ApiPropertyOptional({ + example: 'P1234567', + description: `**Passport number** + +- Required for international passengers +- Mutually exclusive with nationalId +- No verification performed (manual entry only)` + }) + @IsOptional() + @IsString() + passportNumber?: string; + + @ApiPropertyOptional({ + example: 'Kenya', + description: 'Passport issuing country (required if passportNumber provided)' + }) + @IsOptional() + @IsString() + passportCountry?: string; + + @ApiPropertyOptional({ + example: 'Ethiopian', + description: `**Nationality** + +- Auto-filled if Fayda verification succeeds +- Required for international passengers +- Optional for Ethiopian passengers (defaults to "Ethiopian")` + }) + @IsOptional() + @IsString() + nationality?: string; + + @ApiPropertyOptional({ + example: '+251911234567', + description: 'Phone number in international format (optional but recommended)' + }) + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional({ + example: 'abebe@example.com', + description: 'Email address (optional but recommended)' + }) + @IsOptional() + @IsString() + email?: string; + + @ApiPropertyOptional({ + description: `**User ID (auto-populated from JWT token)** + +- Do NOT send this field in request +- Automatically extracted from JWT token if present +- Used to link passenger to user account` + }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ + example: 'device-uuid-123', + description: `**Device ID for guest users** + +- **Required if no JWT token provided (guest mode)** +- Generate once and store locally (localStorage/AsyncStorage) +- Used to retrieve saved passenger profiles +- Format: UUID or any unique string` + }) + @IsOptional() + @IsString() + deviceId?: string; + + @ApiPropertyOptional({ + example: 'Male', + description: 'Gender (auto-filled if Fayda verification succeeds)' + }) + @IsOptional() + @IsString() + gender?: string; + + @ApiPropertyOptional({ + example: true, + description: `**Whether to verify with Fayda (auto-determined)** + +- Default: Auto-detect (true if nationalId provided) +- Set to false to skip Fayda verification (use manual entry) +- Only applicable for Ethiopian nationals with nationalId` + }) + @IsOptional() + @IsBoolean() + verifyWithFayda?: boolean; +} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 9fb265c99..2d839cb6e 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterInternationalPassengerDto } from './passengers.dto'; +import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto'; +import { VerifaydaService } from '../verifayda/verifayda.service'; interface PassengerFilters { search?: string; @@ -11,7 +12,10 @@ interface PassengerFilters { @Injectable() export class PassengersService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private verifaydaService: VerifaydaService, + ) {} async findAll(filters: PassengerFilters = {}) { const { search, verified, page = 1, pageSize = 20 } = filters; @@ -126,29 +130,43 @@ export class PassengersService { return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 }; } - async registerInternational(dto: RegisterInternationalPassengerDto) { - const profile = await this.prisma.savedPassengerProfile.create({ - data: { - userId: dto.userId, - deviceId: dto.deviceId, - passengerName: dto.passengerName, - dateOfBirth: new Date(dto.dateOfBirth), - idDocumentType: 'PASSPORT', - passportNumber: dto.passportNumber, - passportCountry: dto.passportCountry, - nationality: dto.nationality, - phone: dto.phone, - email: dto.email, - }, - }); + async savePassengers(passengers: any[], userId?: string, deviceId?: string) { + if (!passengers || !Array.isArray(passengers)) { + throw new BadRequestException('Passengers array is required'); + } + + if (passengers.length === 0) { + throw new BadRequestException('At least one passenger is required'); + } + + const savedProfiles = await Promise.all( + passengers.map((p) => + this.prisma.savedPassengerProfile.create({ + data: { + userId, + deviceId, + passengerName: p.name, + dateOfBirth: new Date(p.dateOfBirth), + idDocumentType: p.nationalId ? 'NATIONAL_ID' : 'PASSPORT', + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + nationality: p.nationality, + phone: p.phone, + email: p.email, + }, + }) + ) + ); return { - id: profile.id, - passengerName: profile.passengerName, - dateOfBirth: profile.dateOfBirth, - passportNumber: profile.passportNumber, - passportCountry: profile.passportCountry, - nationality: profile.nationality, - message: 'International passenger profile saved successfully', + count: savedProfiles.length, + passengerIds: savedProfiles.map(p => p.id), + passengers: savedProfiles.map(p => ({ + id: p.id, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + nationality: p.nationality, + })), + message: 'Passenger details saved successfully', }; } @@ -161,4 +179,95 @@ export class PassengersService { createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); } getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); } -} + + async registerPassenger(dto: RegisterPassengerDto) { + const isEthiopian = !!dto.nationalId; + const isLoggedIn = !!dto.userId; + let verifiedData: any = null; + + // Auto-verify Ethiopian passengers with national ID if Fayda is enabled + if (isEthiopian && dto.verifyWithFayda !== false) { + try { + const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!); + if (verification.verified && verification.passengerData) { + verifiedData = verification.passengerData; + } + } catch (error) { + // If verification fails, continue with manual data + console.warn('Fayda verification failed, using manual data:', error); + } + } + + // Use verified data if available, otherwise use provided data + const finalData = { + passengerName: verifiedData?.fullName || dto.passengerName, + dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth), + nationality: verifiedData?.nationality || dto.nationality || (isEthiopian ? 'Ethiopian' : null), + gender: verifiedData?.gender || dto.gender, + phone: dto.phone, + email: dto.email, + }; + + // If logged in, update user profile and link passenger + if (isLoggedIn) { + const user = await this.prisma.user.findUnique({ + where: { id: dto.userId }, + include: { passenger: true }, + }); + + if (!user) { + throw new BadRequestException('User not found'); + } + + // Update user record if not already verified + if (!user.faydaVerified && verifiedData) { + await this.prisma.user.update({ + where: { id: dto.userId }, + data: { + fullName: finalData.passengerName, + nationality: finalData.nationality, + nationalId: dto.nationalId, + passportNumber: dto.passportNumber, + faydaVerified: !!verifiedData, + faydaVerifiedAt: verifiedData ? new Date() : null, + }, + }); + } + + return { + id: user.passenger?.id || user.id, + passengerName: finalData.passengerName, + dateOfBirth: finalData.dateOfBirth, + nationality: finalData.nationality, + verified: !!verifiedData, + linked: true, + message: 'Passenger details saved and linked to user account', + }; + } + + // Guest user - save to SavedPassengerProfile + const profile = await this.prisma.savedPassengerProfile.create({ + data: { + deviceId: dto.deviceId, + passengerName: finalData.passengerName, + dateOfBirth: finalData.dateOfBirth, + idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', + passportNumber: dto.passportNumber, + passportCountry: dto.passportCountry, + nationality: finalData.nationality, + phone: dto.phone, + email: dto.email, + }, + }); + + return { + id: profile.id, + passengerName: finalData.passengerName, + dateOfBirth: finalData.dateOfBirth, + nationality: finalData.nationality, + verified: !!verifiedData, + linked: false, + message: 'Passenger details saved for guest booking', + }; + } +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 379cc4de1..820c5b8a0 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -277,7 +277,10 @@ export class SchedulesService { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); if (!schedule) throw new NotFoundException('Schedule not found'); - // Delete related records first + // Delete related records first (in dependency order) + await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } }); + await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } }); + await this.prisma.booking.deleteMany({ where: { scheduleId: id } }); await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts index f0d2f9034..dd155bff8 100644 --- a/apps/edr-passenger-api/src/modules/support/support.service.ts +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -27,7 +27,7 @@ export class SupportService { private getBotReply(text: string): string { const lower = text.toLowerCase(); - if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to My Bookings and select the booking. Refunds are processed within 3-5 business days.'; + if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.'; if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.'; if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.'; return 'Thank you for contacting EDR support. An agent will assist you shortly.'; diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 0ca0774b6..1c67df1b2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -14,12 +14,16 @@ interface RouteStop { stationId: string; sequence: number; distanceKm?: number; + distanceFromOrigin?: number; } export default function RoutesPage() { const [showModal, setShowModal] = useState(false); const [editingRoute, setEditingRoute] = useState(null); const [stops, setStops] = useState([]); + const [originStationId, setOriginStationId] = useState(''); + const [destinationStationId, setDestinationStationId] = useState(''); + const [destinationDistance, setDestinationDistance] = useState(undefined); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -65,21 +69,38 @@ export default function RoutesPage() { e.preventDefault(); const formData = new FormData(e.currentTarget); - if (stops.length < 2) { - alert('Route must have at least 2 stops'); + if (!originStationId || !destinationStationId) { + alert('Please select origin and destination stations'); return; } - const stopsArray = stops.map((stop, idx) => { - const stopData: any = { - stationId: stop.stationId, - sequence: idx + 1, - }; - if (idx > 0 && stop.distanceKm) { - stopData.distanceKm = stop.distanceKm; - } - return stopData; - }); + if (originStationId === destinationStationId) { + alert('Origin and destination must be different'); + return; + } + + // Sort middle stops by distance from origin + const sortedMiddleStops = [...stops].sort((a, b) => + (a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0) + ); + + // Calculate distanceKm (distance from previous stop) + const stopsArray = [ + { stationId: originStationId, sequence: 1, distanceKm: 0 }, + ...sortedMiddleStops.map((stop, idx) => { + const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0); + return { + stationId: stop.stationId, + sequence: idx + 2, + distanceKm: (stop.distanceFromOrigin || 0) - prevDistance, + }; + }), + { + stationId: destinationStationId, + sequence: sortedMiddleStops.length + 2, + distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0), + }, + ]; const routeData = { code: formData.get('code') as string, @@ -100,7 +121,7 @@ export default function RoutesPage() { }; const addStop = () => { - setStops([...stops, { stationId: '', sequence: stops.length + 1 }]); + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); }; const removeStop = (index: number) => { @@ -113,6 +134,20 @@ export default function RoutesPage() { setStops(updated); }; + const generateRouteCode = (originId: string, destId: string) => { + if (!originId || !destId) return ''; + const origin = stations?.items?.find((s: any) => s.id === originId); + const dest = stations?.items?.find((s: any) => s.id === destId); + return origin && dest ? `${origin.code}-${dest.code}` : ''; + }; + + const generateRouteName = (originId: string, destId: string) => { + if (!originId || !destId) return ''; + const origin = stations?.items?.find((s: any) => s.id === originId); + const dest = stations?.items?.find((s: any) => s.id === destId); + return origin && dest ? `${origin.name} - ${dest.name}` : ''; + }; + const handleDelete = async (route: any) => { if (confirm(`Are you sure you want to delete ${route.name}?`)) { await deleteMutation.mutateAsync(route.id); @@ -139,6 +174,35 @@ export default function RoutesPage() { label: 'Edit', onClick: (route: any) => { setEditingRoute(route); + const routeStops = route.stops || []; + if (routeStops.length >= 2) { + setOriginStationId(routeStops[0].stationId); + setDestinationStationId(routeStops[routeStops.length - 1].stationId); + + // Calculate cumulative distance for destination + let cumulativeDistance = 0; + routeStops.forEach((stop: any, idx: number) => { + if (idx > 0) { + cumulativeDistance += stop.distanceKm || 0; + } + }); + setDestinationDistance(cumulativeDistance); + + // Calculate distance from origin for middle stops + const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => { + let distFromOrigin = 0; + for (let i = 1; i <= idx + 1; i++) { + distFromOrigin += routeStops[i].distanceKm || 0; + } + return { + stationId: stop.stationId, + sequence: stop.sequence, + distanceKm: stop.distanceKm, + distanceFromOrigin: distFromOrigin, + }; + }); + setStops(middleStops); + } setShowModal(true); }, variant: 'secondary' as const, @@ -163,6 +227,9 @@ export default function RoutesPage() { icon={Plus} onClick={() => { setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); setStops([]); setShowModal(true); }} @@ -185,12 +252,52 @@ export default function RoutesPage() { onClose={() => { setShowModal(false); setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); setStops([]); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} size="lg" > -
+ +
+
+ + +
+
+ + +
+
+
@@ -198,9 +305,10 @@ export default function RoutesPage() { type="text" name="code" className="input" - defaultValue={editingRoute?.code} + value={generateRouteCode(originStationId, destinationStationId)} + readOnly required - placeholder="e.g., ADD-DJI" + placeholder="Select stations to generate" disabled={!!editingRoute} />
@@ -210,9 +318,10 @@ export default function RoutesPage() { type="text" name="name" className="input" - defaultValue={editingRoute?.name} + value={generateRouteName(originStationId, destinationStationId)} + readOnly required - placeholder="e.g., Addis Ababa – Djibouti" + placeholder="Select stations to generate" />
@@ -252,56 +361,66 @@ export default function RoutesPage() {
- - - Add Stop - +
- {stops.length === 0 && ( -

No stops added. Click "Add Stop" to begin.

- )} +
+ {/* Origin Stop */} +
+
+ 1 +
+
+ {originStationId ? ( + + {stations?.items?.find((s: any) => s.id === originStationId)?.name || 'Unknown'} + {' '}({stations?.items?.find((s: any) => s.id === originStationId)?.code || 'N/A'}) + + ) : ( + Select origin station above + )} +
+
+ 0 km +
+
-
+ {/* Intermediate Stops */} {stops.map((stop, index) => ( -
-
- {index + 1} +
+
+ {index + 2}
-
-
- -
-
- updateStop(index, 'distanceKm', e.target.value ? parseFloat(e.target.value) : undefined)} - disabled={index === 0} - min="0" - step="0.1" - /> -
+
+ +
+
+ updateStop(index, 'distanceFromOrigin', e.target.value ? parseFloat(e.target.value) : undefined)} + min="0" + step="0.1" + required + />
))} + + {/* Add Intermediate Stop Button */} + {originStationId && destinationStationId && ( +
+ + Add Intermediate Stop + +
+ )} + + {/* Destination Stop */} +
+
+ {stops.length + 2} +
+
+ {destinationStationId ? ( + + {stations?.items?.find((s: any) => s.id === destinationStationId)?.name || 'Unknown'} + {' '}({stations?.items?.find((s: any) => s.id === destinationStationId)?.code || 'N/A'}) + + ) : ( + Select destination station above + )} +
+
+ {destinationStationId && ( + setDestinationDistance(e.target.value ? parseFloat(e.target.value) : undefined)} + min="0" + step="0.1" + required + /> + )} +
+
@@ -322,6 +487,9 @@ export default function RoutesPage() { onClick={() => { setShowModal(false); setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); setStops([]); }} > diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index b6c2acc41..3a04e0445 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -77,6 +77,14 @@ export default function SchedulesPage() { }, }); + const removeCoachMutation = useMutation({ + mutationFn: ({ scheduleId, coachId }: { scheduleId: string; coachId: string }) => + schedulesApi.removeCoachAssignment(scheduleId, coachId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['schedules'] }); + }, + }); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); @@ -119,6 +127,12 @@ export default function SchedulesPage() { setShowCoachModal(true); }; + const handleRemoveCoach = async (schedule: any, coachId: string) => { + if (confirm('Remove this coach from the schedule?')) { + await removeCoachMutation.mutateAsync({ scheduleId: schedule.id, coachId }); + } + }; + const handleToggleCoach = (coachId: string) => { setSelectedCoaches(prev => { const exists = prev.find(c => c.coachId === coachId); @@ -157,9 +171,21 @@ export default function SchedulesPage() { return (
{schedule.coachAssignments?.slice(0, 3).map((assignment: any) => ( - - {assignment.coach?.coachNumber || 'N/A'} - +
+ + {assignment.coach?.coachNumber || 'N/A'} + + +
))} {coachCount > 3 && ( diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 8f7b5c9e6..605abc699 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -3,13 +3,17 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { seatsApi, schedulesApi } from '@/lib/api'; -import DataTable from '@/components/ui/DataTable'; +import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; -import { Search, Armchair, Lock, Unlock } from 'lucide-react'; +import Badge from '@/components/ui/Badge'; +import { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react'; export default function SeatsPage() { const [search, setSearch] = useState(''); const [selectedSchedule, setSelectedSchedule] = useState(''); + const [showBlockModal, setShowBlockModal] = useState(false); + const [selectedSeat, setSelectedSeat] = useState(null); + const [blockReason, setBlockReason] = useState(''); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -17,114 +21,85 @@ export default function SeatsPage() { queryFn: () => schedulesApi.getAll(), }); - const { data, isLoading } = useQuery({ - queryKey: ['seats', selectedSchedule], - queryFn: () => selectedSchedule ? seatsApi.getBySchedule(selectedSchedule) : Promise.resolve([]), + const { data: seatMapData, isLoading } = useQuery({ + queryKey: ['seatmap', selectedSchedule], + queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null), enabled: !!selectedSchedule, }); const blockMutation = useMutation({ mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + setShowBlockModal(false); + setSelectedSeat(null); + setBlockReason(''); + }, }); const unblockMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.unblock(seatId), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + }, }); - const seats = Array.isArray(data) ? data : data?.items || data?.data || []; const schedules = schedulesData?.items || schedulesData?.data || []; + const coaches = seatMapData?.coaches || []; - const columns = [ - { - key: 'seatNumber', - label: 'Seat Number', - render: (seat: any) => ( -
-
- -
- {seat.seatNumber} -
- ), - }, - { - key: 'coach', - label: 'Coach', - render: (seat: any) => ( - {seat.coach?.coachNumber || 'N/A'} - ), - }, - { - key: 'seatClass', - label: 'Class', - render: (seat: any) => { - const seatClass = seat.coach?.serviceClass || 'N/A'; - const colorMap: Record = { - 'ECONOMY_REGULAR': 'edr-badge-info', - 'ECONOMY_BED': 'edr-badge-warning', - 'VIP_BED': 'edr-badge-success', - }; - return ( - - {seatClass.replace(/_/g, ' ')} - - ); - }, - }, - { - key: 'position', - label: 'Position', - render: (seat: any) => ( - - {seat.position || seat.seatPosition || 'N/A'} - - ), - }, - { - key: 'status', - label: 'Status', - render: (seat: any) => { - const isBlocked = seat.isBlocked || seat.status === 'BLOCKED'; - const isBooked = seat.isBooked || seat.status === 'BOOKED'; - - if (isBlocked) return Blocked; - if (isBooked) return Booked; - return Available; - }, - }, - ]; + const handleBlock = (seat: any) => { + setSelectedSeat(seat); + setShowBlockModal(true); + }; - const actions = [ - { - label: 'Block', - onClick: (seat: any) => blockMutation.mutate({ seatId: seat.id, reason: 'Manual block' }), - variant: 'secondary' as const, - icon: Lock, - show: (seat: any) => !seat.isBlocked && seat.status !== 'BLOCKED', - }, - { - label: 'Unblock', - onClick: (seat: any) => unblockMutation.mutate(seat.id), - variant: 'secondary' as const, - icon: Unlock, - show: (seat: any) => seat.isBlocked || seat.status === 'BLOCKED', - }, - ]; + const handleUnblock = async (seat: any) => { + if (confirm('Are you sure you want to unblock this seat?')) { + await unblockMutation.mutateAsync(seat.id); + } + }; + + const submitBlock = async () => { + if (!blockReason.trim()) { + alert('Please provide a reason for blocking'); + return; + } + await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason }); + }; + + const getSeatStatus = (seat: any) => { + if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED'; + if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED'; + if (seat.status === 'HELD') return 'HELD'; + return 'AVAILABLE'; + }; + + const getSeatColor = (status: string) => { + switch (status) { + case 'AVAILABLE': return 'bg-green-500'; + case 'BOOKED': return 'bg-red-500'; + case 'HELD': return 'bg-yellow-500'; + case 'BLOCKED': return 'bg-gray-500'; + default: return 'bg-gray-300'; + } + }; + + const filteredCoaches = coaches.filter((coach: any) => + search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true + ); return (

Seat Management

-

Manage seat availability and blocking

+

View and manage seat availability by schedule

+
- - setSearch(e.target.value)} - className="input pl-10" - /> + +
+ + setSearch(e.target.value)} + className="input pl-10" + /> +
- {selectedSchedule ? ( - - ) : ( + {!selectedSchedule ? (
- Select a schedule to view seats + +

Select a schedule to view seat map

+
+ ) : isLoading ? ( +
+
+

Loading seats...

+
+ ) : filteredCoaches.length === 0 ? ( +
+

No coaches found for this schedule

+
+ ) : ( +
+ {/* Legend */} +
+
+
+ Available +
+
+
+ Booked +
+
+
+ Held +
+
+
+ Blocked +
+
+ + {/* Coaches */} + {filteredCoaches.map((coach: any) => { + const seats = coach.seats || []; + const seatClass = coach.seatClass?.name || 'N/A'; + const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length; + const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length; + const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length; + + return ( +
+
+
+

+ Coach {coach.coachNumber} - {coach.label} +

+

+ {seatClass} • {seats.length} seats +

+
+
+ Available: {availableCount} + Booked: {bookedCount} + Blocked: {blockedCount} +
+
+ +
+ {seats.map((seat: any) => { + const status = getSeatStatus(seat); + const color = getSeatColor(status); + const canBlock = status === 'AVAILABLE'; + const canUnblock = status === 'BLOCKED'; + + return ( +
+
+ {seat.seatNumber} +
+ {(canBlock || canUnblock) && ( +
+ {canBlock && ( + + )} + {canUnblock && ( + + )} +
+ )} +
+ ); + })} +
+
+ ); + })}
)}
+ + {/* Block Modal */} + { + setShowBlockModal(false); + setSelectedSeat(null); + setBlockReason(''); + }} + title="Block Seat" + size="md" + > +
+

+ Block seat {selectedSeat?.seatNumber} in Coach{' '} + {selectedSeat?.coach?.coachNumber} +

+
+ +