Merge branch 'alpha' into passenger/feat/iam-integration

This commit is contained in:
Abubeker Yasin
2026-06-03 10:20:25 +03:00
102 changed files with 4839 additions and 1993 deletions

View File

@@ -219,31 +219,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();
@@ -255,6 +256,8 @@ Payment providers send notifications to:
persistAuthorization: true,
docExpansion: "none",
filter: true,
tagsSorter: "alpha",
operationsSorter: "alpha",
},
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,28 +71,42 @@ export class GuestBookingService {
let verifaydaData: Record<string, any> | 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',
},

View File

@@ -1,11 +1,12 @@
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 { VerifaydaService } from '../verifayda/verifayda.service';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@ApiTags('Passenger')
@ApiTags('Passengers')
@Controller('passengers')
export class PassengersController {
constructor(
@@ -55,14 +56,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,
@@ -84,21 +122,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')

View File

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

View File

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

View File

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

View File

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