mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Enhance passenger API and web application functionality
This commit is contained in:
@@ -22,7 +22,7 @@ async function bootstrap() {
|
||||
new ResponseTransformInterceptor(),
|
||||
app.get(SessionActivityInterceptor),
|
||||
);
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false }));
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("EDR Passenger API")
|
||||
|
||||
@@ -219,21 +219,41 @@ The API automatically detects:
|
||||
|
||||
@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.
|
||||
summary: 'Bulk save passenger details from booking flow',
|
||||
description: `**Endpoint for saving multiple passengers in a single booking**
|
||||
|
||||
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
|
||||
### Purpose
|
||||
Save all passenger details for a multi-passenger booking before proceeding to seat selection. Optimized for batch operations where all passengers are collected upfront.
|
||||
|
||||
**Migration:** Use \`POST /passengers/register\` for new implementations.`,
|
||||
---
|
||||
|
||||
### Use Cases
|
||||
1. **Multi-passenger bookings** - Save all passengers in a single request
|
||||
2. **Batch registration** - Admin/Agent registering multiple passengers at once
|
||||
3. **Data preservation** - Save passenger data before proceeding to seat selection
|
||||
4. **Guest bookings** - Multiple guests booking together
|
||||
|
||||
---
|
||||
|
||||
### Differences from /register
|
||||
| Feature | /register | /save-details |
|
||||
|---------|-----------|---------------|
|
||||
| Purpose | Single passenger registration with optional verification | Bulk save multiple passengers |
|
||||
| Passengers | One at a time | Multiple in array |
|
||||
| Verification | Auto-attempts for Ethiopian nationals (if enabled) | No automatic verification |
|
||||
| Use case | Individual registration flow | Booking flow with all passengers |
|
||||
| Authentication | Optional JWT | Optional JWT |
|
||||
|
||||
---
|
||||
|
||||
### Response
|
||||
Returns saved passenger details with generated IDs and confirmation.`,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Passenger details saved successfully',
|
||||
description: 'All passenger details saved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
count: 2,
|
||||
@@ -241,15 +261,17 @@ Saves all passenger details to database before proceeding to seat selection.
|
||||
passengers: [
|
||||
{
|
||||
id: 'uuid-1',
|
||||
passengerName: 'John Doe',
|
||||
dateOfBirth: '1990-01-01T00:00:00.000Z',
|
||||
nationality: 'Ethiopian'
|
||||
passengerName: 'Abebe Kebede',
|
||||
dateOfBirth: '1985-03-15T00:00:00.000Z',
|
||||
nationality: 'Ethiopian',
|
||||
nationalId: 'ET123456789'
|
||||
},
|
||||
{
|
||||
id: 'uuid-2',
|
||||
passengerName: 'Jane Doe',
|
||||
dateOfBirth: '1992-05-15T00:00:00.000Z',
|
||||
nationality: 'Ethiopian'
|
||||
passengerName: 'Sara Ketsela',
|
||||
dateOfBirth: '1990-08-22T00:00:00.000Z',
|
||||
nationality: 'Ethiopian',
|
||||
nationalId: 'ET987654321'
|
||||
}
|
||||
],
|
||||
message: 'Passenger details saved successfully'
|
||||
@@ -257,9 +279,8 @@ Saves all passenger details to database before proceeding to seat selection.
|
||||
}
|
||||
})
|
||||
@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);
|
||||
savePassengers(@Body() dto: SavePassengersDto) {
|
||||
return this.service.savePassengers(dto.passengers, dto.userId, dto.deviceId);
|
||||
}
|
||||
|
||||
@Post('traveler-profiles')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean } from 'class-validator';
|
||||
import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTravelerProfileDto {
|
||||
@@ -76,51 +77,60 @@ export class RegisterInternationalPassengerDto {
|
||||
}
|
||||
|
||||
export class SavePassengerDetailsDto {
|
||||
@ApiProperty({ example: 'Abebe Kebede' })
|
||||
@ApiProperty({ example: 'Abebe Kebede', description: 'Full name of passenger' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: '1985-03-15' })
|
||||
@ApiProperty({ example: '1985-03-15', description: 'Date of birth in ISO format YYYY-MM-DD' })
|
||||
@IsDateString()
|
||||
dateOfBirth: string;
|
||||
|
||||
@ApiProperty({ example: 'ETHIOPIAN' })
|
||||
@IsString()
|
||||
nationality: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ET123456789' })
|
||||
@ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID (for Ethiopian passengers)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'P1234567' })
|
||||
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number (for international passengers)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passportNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Kenya' })
|
||||
@ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passportCountry?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+251911234567' })
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationality?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+251911234567', description: 'Phone number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'email@example.com' })
|
||||
@ApiPropertyOptional({ example: 'abebe@example.com', description: 'Email address' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ example: 'Male', description: 'Gender' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
faydaSub?: string;
|
||||
gender?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether this is the primary passenger' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimaryPassenger?: boolean;
|
||||
}
|
||||
|
||||
export class SavePassengersDto {
|
||||
@ApiProperty({ type: [SavePassengerDetailsDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SavePassengerDetailsDto)
|
||||
passengers: SavePassengerDetailsDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'User ID if logged in' })
|
||||
|
||||
@@ -145,7 +145,7 @@ export class PassengersService {
|
||||
data: {
|
||||
userId,
|
||||
deviceId,
|
||||
passengerName: p.name,
|
||||
passengerName: p.name || p.passengerName,
|
||||
dateOfBirth: new Date(p.dateOfBirth),
|
||||
idDocumentType: p.nationalId ? 'NATIONAL_ID' : 'PASSPORT',
|
||||
passportNumber: p.passportNumber,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_API_URL=https://your-api-domain.com
|
||||
|
||||
# IAM Configuration (Corporate Authentication)
|
||||
NEXT_PUBLIC_IAM_ENABLED=false
|
||||
NEXT_PUBLIC_IAM_API_URL=https://iam.tria-plc.com/api
|
||||
|
||||
# GitHub Packages Token
|
||||
GITHUB_PACKAGE_TOKEN=$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
|
||||
|
||||
@@ -92,7 +92,6 @@ export default function SchedulesPage() {
|
||||
const departureAt = formData.get('departureAt') as string;
|
||||
const arrivalAt = formData.get('arrivalAt') as string;
|
||||
|
||||
// Convert datetime-local to ISO 8601
|
||||
const departureISO = new Date(departureAt).toISOString();
|
||||
const arrivalISO = new Date(arrivalAt).toISOString();
|
||||
|
||||
@@ -101,7 +100,7 @@ export default function SchedulesPage() {
|
||||
routeId: formData.get('routeId') as string,
|
||||
departureAt: departureISO,
|
||||
arrivalAt: arrivalISO,
|
||||
plannedTimes: [], // Will be auto-generated by backend based on route stops
|
||||
plannedTimes: [],
|
||||
};
|
||||
|
||||
if (editingSchedule) {
|
||||
|
||||
@@ -16,6 +16,10 @@ export const formatDateTime = (date: string | Date): string => {
|
||||
return format(new Date(date), 'MMM dd, yyyy HH:mm');
|
||||
};
|
||||
|
||||
export const formatDateTimeLocal = (date: string | Date): string => {
|
||||
return format(new Date(date), 'MMM dd, yyyy HH:mm');
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3002
|
||||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=https://your-api-domain.com
|
||||
|
||||
# GitHub Packages Token
|
||||
GITHUB_PACKAGE_TOKEN=$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
|
||||
@@ -112,68 +112,55 @@ export default function PassengersPage() {
|
||||
}, [isAuthenticated, user, searchCriteria, setValue]);
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return; // Guard for SSR
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const faydaUrl = process.env.NEXT_PUBLIC_FAYDA_URL || 'https://fayda.gov.et/verify';
|
||||
const callbackUrl = `${window.location.origin}/booking/passengers?faydaCallback=${index}`;
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = (window.screen.width - width) / 2;
|
||||
const top = (window.screen.height - height) / 2;
|
||||
|
||||
window.open(
|
||||
`${faydaUrl}?callback=${encodeURIComponent(callbackUrl)}`,
|
||||
'FaydaVerification',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||
);
|
||||
try {
|
||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||
purpose: 'PURCHASE',
|
||||
platform: 'WEB',
|
||||
saveToAccount: index === 0 && isAuthenticated,
|
||||
});
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (event.data?.type === 'FAYDA_VERIFIED' && event.data?.index === index) {
|
||||
const data = event.data.passengerData;
|
||||
setValue(`passengers.${index}.name`, data.fullName);
|
||||
setValue(`passengers.${index}.dateOfBirth`, data.dateOfBirth.split('T')[0]);
|
||||
setValue(`passengers.${index}.gender`, data.gender);
|
||||
setValue(`passengers.${index}.nationality`, data.nationality || 'ETHIOPIAN');
|
||||
setValue(`passengers.${index}.phone`, data.phone || '');
|
||||
setValue(`passengers.${index}.email`, data.email || '');
|
||||
setValue(`passengers.${index}.faydaVerified`, true);
|
||||
setValue(`passengers.${index}.faydaSub`, data.faydaSub);
|
||||
setValue(`passengers.${index}.formExpanded`, true);
|
||||
setVerificationStatus({ ...verificationStatus, [index]: 'success' });
|
||||
const authorizationUrl = response.authorizationUrl;
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = (window.screen.width - width) / 2;
|
||||
const top = (window.screen.height - height) / 2;
|
||||
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
'FaydaVerification',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||
);
|
||||
|
||||
if (index === 0 && isAuthenticated && user) {
|
||||
setUpdatingUser(true);
|
||||
const checkPopup = setInterval(async () => {
|
||||
if (popup?.closed) {
|
||||
clearInterval(checkPopup);
|
||||
try {
|
||||
await apiClient.patch('/auth/profile', {
|
||||
fullName: data.fullName,
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
gender: data.gender,
|
||||
nationality: data.nationality || 'ETHIOPIAN',
|
||||
phone: data.phone,
|
||||
faydaVerified: true,
|
||||
faydaSub: data.faydaSub,
|
||||
});
|
||||
updateUser({
|
||||
fullName: data.fullName,
|
||||
dateOfBirth: data.dateOfBirth.split('T')[0],
|
||||
gender: data.gender,
|
||||
nationality: data.nationality || 'ETHIOPIAN',
|
||||
phone: data.phone,
|
||||
faydaVerified: true,
|
||||
faydaSub: data.faydaSub,
|
||||
});
|
||||
const statusResponse: any = await apiClient.get('/fayda/verification/status');
|
||||
if (statusResponse.verified) {
|
||||
setValue(`passengers.${index}.name`, statusResponse.fullName || '');
|
||||
setValue(`passengers.${index}.faydaVerified`, true);
|
||||
setValue(`passengers.${index}.formExpanded`, true);
|
||||
setVerificationStatus({ ...verificationStatus, [index]: 'success' });
|
||||
|
||||
if (index === 0 && isAuthenticated) {
|
||||
updateUser({
|
||||
fullName: statusResponse.fullName,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: statusResponse.verifiedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update user profile:', error);
|
||||
} finally {
|
||||
setUpdatingUser(false);
|
||||
console.error('Failed to get verification status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
window.removeEventListener('message', handleMessage);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to start Fayda verification:', error);
|
||||
alert('Failed to start verification. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleForm = (index: number) => {
|
||||
@@ -184,11 +171,18 @@ export default function PassengersPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const passengerDetails = data.passengers.map((p, i) => ({
|
||||
...p,
|
||||
name: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
gender: p.gender,
|
||||
nationality: p.nationality,
|
||||
nationalId: p.nationalId,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
phone: p.phone,
|
||||
email: p.email,
|
||||
isPrimaryPassenger: i === 0,
|
||||
}));
|
||||
|
||||
// Save passenger details to database before proceeding
|
||||
const deviceId = typeof window !== 'undefined'
|
||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||
: crypto.randomUUID();
|
||||
@@ -271,7 +265,7 @@ export default function PassengersPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleForm(index)}
|
||||
className="text-sm text-primary hover:underline mt-2"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:underline mt-2"
|
||||
>
|
||||
Or enter details manually
|
||||
</button>
|
||||
|
||||
@@ -120,7 +120,7 @@ export function dayOfYearToDate(year: number, dayOfYear: number): Date {
|
||||
*/
|
||||
export function formatEthiopianDate(ethDate: EthiopianDate): string {
|
||||
const monthName = ETHIOPIAN_MONTHS[ethDate.month - 1] || 'Unknown';
|
||||
return `${ethDate.day} ${monthName} ${ethDate.year}`;
|
||||
return `${monthName} ${ethDate.day}, ${ethDate.year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
26
apps/edr-passenger-web/portal/src/utils/format.ts
Normal file
26
apps/edr-passenger-web/portal/src/utils/format.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { format, toZonedTime } from 'date-fns-tz';
|
||||
|
||||
const ADDIS_TZ = 'Africa/Addis_Ababa';
|
||||
|
||||
export const formatCurrency = (amount: number, currency: string = 'ETB'): string => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => {
|
||||
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ);
|
||||
return format(zonedDate, formatStr, { timeZone: ADDIS_TZ });
|
||||
};
|
||||
|
||||
export const formatDateTime = (date: string | Date): string => {
|
||||
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ);
|
||||
return format(zonedDate, 'MMM dd, yyyy HH:mm', { timeZone: ADDIS_TZ });
|
||||
};
|
||||
|
||||
export const formatTime = (date: string | Date): string => {
|
||||
const zonedDate = toZonedTime(new Date(date), ADDIS_TZ);
|
||||
return format(zonedDate, 'HH:mm', { timeZone: ADDIS_TZ });
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"extends": "../../../packages/config/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
Reference in New Issue
Block a user