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

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