mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
Merge branch 'alpha' into passenger/feat/iam-integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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.';
|
||||
|
||||
Reference in New Issue
Block a user