mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Boarding, payment methods, journey direction on seat hold, and more updates
This commit is contained in:
@@ -21,22 +21,152 @@ export class PassengersController {
|
||||
@Get()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'List all passengers with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of passengers with search filters'
|
||||
summary: 'List all travelers with filters (Admin/Agent)',
|
||||
description: `**Returns paginated list of all travelers in the system**
|
||||
|
||||
---
|
||||
|
||||
### Data Source
|
||||
- Fetches from **TravelerProfile** table (created during booking)
|
||||
- Shows ALL passengers from ALL bookings (including guest bookings)
|
||||
- Each row represents a unique traveler, not a user account
|
||||
|
||||
---
|
||||
|
||||
### Features
|
||||
- Search by name, email, phone
|
||||
- Filter by gender
|
||||
- Date range filtering (createdAt)
|
||||
- Pagination support (page, pageSize)
|
||||
- Includes loyalty and wallet info if linked to user account
|
||||
- Shows booking count per traveler
|
||||
|
||||
---
|
||||
|
||||
### Response Fields
|
||||
- **id**: TravelerProfile ID
|
||||
- **fullName**: Passenger name
|
||||
- **email/phone**: Contact info (from linked user or booking)
|
||||
- **gender**: Male/Female/Other (from Verifayda or manual entry)
|
||||
- **dateOfBirth**: Birth date in YYYY-MM-DD format
|
||||
- **nationality**: Passenger nationality
|
||||
- **faydaVerified**: Whether verified via Verifayda
|
||||
- **loyaltyTier/loyaltyPoints**: If linked to user account
|
||||
- **totalBookings**: Number of bookings
|
||||
- **createdAt**: When traveler was first added to system`
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
|
||||
@ApiQuery({ name: 'gender', required: false, description: 'Filter by gender (Male, Female, Other)' })
|
||||
@ApiQuery({ name: 'dateFrom', required: false, description: 'Filter by creation date from (YYYY-MM-DD)' })
|
||||
@ApiQuery({ name: 'dateTo', required: false, description: 'Filter by creation date to (YYYY-MM-DD)' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page (default: 20)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Travelers retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
id: 'uuid-123',
|
||||
fullName: 'Abebe Kebede',
|
||||
email: 'abebe@example.com',
|
||||
phone: '+251911234567',
|
||||
gender: 'Male',
|
||||
dateOfBirth: '1985-03-15',
|
||||
nationality: 'Ethiopian',
|
||||
faydaVerified: true,
|
||||
loyaltyTier: 'SILVER',
|
||||
loyaltyPoints: 1500,
|
||||
totalBookings: 5,
|
||||
createdAt: '2024-01-10T12:00:00.000Z'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 150,
|
||||
totalPages: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Travelers retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
id: 'uuid-123',
|
||||
fullName: 'Abebe Kebede',
|
||||
email: 'abebe@example.com',
|
||||
phone: '+251911234567',
|
||||
gender: 'Male',
|
||||
dateOfBirth: '1985-03-15',
|
||||
nationality: 'Ethiopian',
|
||||
nationalityCode: 'ET',
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
|
||||
passportNumber: null,
|
||||
passportCountry: null,
|
||||
passportExpiryDate: null,
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
verified: true,
|
||||
lastLoginAt: '2024-01-20T08:15:00.000Z',
|
||||
role: 'PASSENGER',
|
||||
loyalty: {
|
||||
tier: 'SILVER',
|
||||
pointsBalance: 1500,
|
||||
lifetimePoints: 3000
|
||||
},
|
||||
wallet: {
|
||||
balanceMinor: 50000,
|
||||
currency: 'ETB'
|
||||
},
|
||||
loyaltyTier: 'SILVER',
|
||||
loyaltyPoints: 1500,
|
||||
totalBookings: 5,
|
||||
createdAt: '2024-01-10T12:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'uuid-456',
|
||||
fullName: 'Sara Ketsela',
|
||||
email: null,
|
||||
phone: null,
|
||||
gender: 'Female',
|
||||
dateOfBirth: '1990-08-22',
|
||||
nationality: 'Ethiopian',
|
||||
nationalityCode: null,
|
||||
faydaVerified: false,
|
||||
faydaVerifiedAt: null,
|
||||
passportNumber: null,
|
||||
passportCountry: null,
|
||||
passportExpiryDate: null,
|
||||
idDocumentType: null,
|
||||
verified: false,
|
||||
lastLoginAt: null,
|
||||
role: null,
|
||||
loyalty: null,
|
||||
wallet: null,
|
||||
loyaltyTier: 'BRONZE',
|
||||
loyaltyPoints: 0,
|
||||
totalBookings: 1,
|
||||
createdAt: '2024-01-18T14:30:00.000Z'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 150,
|
||||
totalPages: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'verified', required: false })
|
||||
@ApiQuery({ name: 'gender', required: false })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('verified') verified?: string,
|
||||
@Query('gender') gender?: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@@ -44,9 +174,7 @@ export class PassengersController {
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
verified: verified ? verified === 'true' : undefined,
|
||||
gender,
|
||||
nationality,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
|
||||
@@ -8,6 +8,7 @@ export class CreateTravelerProfileDto {
|
||||
@ApiProperty({ example: 'SPOUSE' }) @IsString() relationship: string;
|
||||
@ApiPropertyOptional({ example: '1998-04-01' }) @IsOptional() @IsDateString() dateOfBirth?: string;
|
||||
@ApiPropertyOptional({ example: 'ET-1234-5678' }) @IsOptional() @IsString() nationalId?: string;
|
||||
@ApiPropertyOptional({ example: 'Female' }) @IsOptional() @IsString() gender?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
interface PassengerFilters {
|
||||
search?: string;
|
||||
verified?: boolean;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
@@ -33,31 +32,21 @@ export class PassengersService {
|
||||
) {}
|
||||
|
||||
async findAll(filters: PassengerFilters = {}) {
|
||||
const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const { search, gender, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.user = {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (verified !== undefined) {
|
||||
where.user = { ...(where.user ?? {}), faydaVerified: verified };
|
||||
where.OR = [
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (gender) {
|
||||
where.user = { ...(where.user ?? {}), gender };
|
||||
}
|
||||
|
||||
if (nationality) {
|
||||
where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } };
|
||||
where.gender = gender;
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
@@ -68,34 +57,43 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.passenger.findMany({
|
||||
this.prisma.travelerProfile.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: { select: { bookings: true } },
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
seats: { take: 1, orderBy: { id: 'asc' }, select: {
|
||||
passengerName: true, dateOfBirth: true, passportNumber: true,
|
||||
passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true,
|
||||
}},
|
||||
passenger: {
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: { select: { bookings: true } },
|
||||
bookings: {
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
contactPhone: true,
|
||||
contactEmail: true,
|
||||
seats: {
|
||||
take: 1,
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
passengerName: true,
|
||||
passportNumber: true,
|
||||
passportCountry: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.passenger.count({ where }),
|
||||
this.prisma.travelerProfile.count({ where }),
|
||||
]);
|
||||
|
||||
const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[];
|
||||
const iamUserIds = items.map(p => p.passenger?.iamUserId).filter(Boolean) as string[];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`,
|
||||
@@ -104,72 +102,51 @@ export class PassengersService {
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
// Collect guest contact details for bulk SavedPassengerProfile lookup
|
||||
const guestContacts = items
|
||||
.filter(p => !(p as any).user && !p.iamUserId)
|
||||
.map(p => (p as any).bookings?.[0])
|
||||
.filter(Boolean);
|
||||
const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[];
|
||||
const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[];
|
||||
|
||||
const savedProfiles = (guestEmails.length || guestPhones.length)
|
||||
? await this.prisma.savedPassengerProfile.findMany({
|
||||
where: { OR: [
|
||||
...(guestEmails.length ? [{ email: { in: guestEmails } }] : []),
|
||||
...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []),
|
||||
]},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Index by email then phone for O(1) lookup
|
||||
const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s]));
|
||||
const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s]));
|
||||
|
||||
return {
|
||||
items: items.map(passenger => {
|
||||
const localUser = (passenger as any).user ?? null;
|
||||
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
items: items.map(profile => {
|
||||
const passenger = profile.passenger;
|
||||
const localUser = (passenger as any)?.user ?? null;
|
||||
const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
const faydaVerified = localUser?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === 'true';
|
||||
const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null;
|
||||
|
||||
// Get additional data from bookings for guest passengers
|
||||
const guestBooking = (passenger as any)?.bookings?.[0] ?? null;
|
||||
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
||||
const savedProfile = guestBooking
|
||||
? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: passenger.id,
|
||||
fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null,
|
||||
email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null,
|
||||
phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null,
|
||||
gender: localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||
dateOfBirth: localUser?.dateOfBirth
|
||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||
: (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth
|
||||
? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0]
|
||||
: (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))),
|
||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null,
|
||||
id: profile.id,
|
||||
fullName: profile.fullName,
|
||||
email: localUser?.email ?? iam?.email ?? guestBooking?.contactEmail ?? null,
|
||||
phone: localUser?.phone ?? iam?.phone_number ?? guestBooking?.contactPhone ?? null,
|
||||
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||
dateOfBirth: profile.dateOfBirth
|
||||
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
|
||||
: (localUser?.dateOfBirth
|
||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||
: iam?.metadata?.dateOfBirth ?? null),
|
||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
|
||||
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
|
||||
faydaVerified,
|
||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null,
|
||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
|
||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
|
||||
idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null,
|
||||
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : null,
|
||||
verified: faydaVerified,
|
||||
lastLoginAt: localUser?.lastLoginAt ?? null,
|
||||
role: localUser?.role ?? null,
|
||||
loyalty: passenger.loyalty
|
||||
loyalty: passenger?.loyalty
|
||||
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
|
||||
: null,
|
||||
wallet: (passenger as any).wallet
|
||||
wallet: (passenger as any)?.wallet
|
||||
? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' }
|
||||
: null,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
createdAt: passenger.createdAt,
|
||||
loyaltyTier: passenger?.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger?.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger?._count?.bookings || 0,
|
||||
createdAt: profile.createdAt,
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
@@ -299,8 +276,13 @@ export class PassengersService {
|
||||
createTravelerProfile(dto: CreateTravelerProfileDto) {
|
||||
return this.prisma.travelerProfile.create({
|
||||
data: {
|
||||
...dto,
|
||||
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
|
||||
passengerId: dto.passengerId,
|
||||
fullName: dto.fullName,
|
||||
relationship: dto.relationship,
|
||||
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null,
|
||||
nationalId: dto.nationalId || null,
|
||||
gender: dto.gender || null,
|
||||
notes: dto.notes || null,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -440,9 +422,21 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
async deletePassenger(id: string) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
});
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
// Check usage before allowing deletion
|
||||
const usage = await this.checkPassengerUsage(id);
|
||||
if (usage.isInUse && usage.constraints) {
|
||||
const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`;
|
||||
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
|
||||
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
|
||||
@@ -470,14 +464,15 @@ export class PassengersService {
|
||||
this.prisma.walletAccount.findUnique({ where: { passengerId: id } }),
|
||||
]);
|
||||
|
||||
const usage = [];
|
||||
if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`);
|
||||
if (loyaltyAccount) usage.push('Loyalty account');
|
||||
if (walletAccount) usage.push('Wallet account');
|
||||
const constraints = [];
|
||||
if (bookingCount > 0) constraints.push({ entityName: 'booking', count: bookingCount, action: 'complete' as const });
|
||||
if (loyaltyAccount) constraints.push({ entityName: 'loyalty account', count: 1, action: 'delete' as const });
|
||||
if (walletAccount) constraints.push({ entityName: 'wallet account', count: 1, action: 'delete' as const });
|
||||
|
||||
return {
|
||||
isInUse: usage.length > 0,
|
||||
affectedModules: usage,
|
||||
isInUse: constraints.length > 0,
|
||||
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
|
||||
constraints
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user