refactor( iam ): migrate guest booking to IAM; make Passenger.userId nullable

This commit is contained in:
Abubeker Yasin
2026-06-08 15:27:32 +03:00
parent ac153fb0d0
commit c1dcfb04c3
11 changed files with 99 additions and 357 deletions

View File

@@ -40,7 +40,6 @@
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dotenv": "^17.4.2",
@@ -60,7 +59,6 @@
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",

View File

@@ -0,0 +1,30 @@
-- DropForeignKey
ALTER TABLE "Passenger" DROP CONSTRAINT "Passenger_userId_fkey";
-- AlterTable
ALTER TABLE "Passenger" ALTER COLUMN "userId" DROP NOT NULL;
-- CreateTable
CREATE TABLE "TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
-- AddForeignKey
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -258,12 +258,12 @@ model Session {
model Passenger {
id String @id @default(uuid())
userId String @unique
userId String? @unique
iamUserId String? @unique
defaultTravelerProfileId String?
preferredLanguage String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
user User? @relation(fields: [userId], references: [id])
bookings Booking[]
loyalty LoyaltyAccount?
wallet WalletAccount?

View File

@@ -5,5 +5,6 @@ import { PassengerAuthService } from './passenger-auth.service';
@Module({
controllers: [AuthController],
providers: [PassengerAuthService],
exports: [PassengerAuthService],
})
export class AuthModule {}

View File

@@ -125,8 +125,8 @@ export class BookingsController {
})
@ApiResponse({ status: 201, description: 'Booking created successfully' })
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' })
createGuest(@Body() dto: CreateGuestBookingDto) {
return this.guestService.createGuestBooking(dto);
createGuest(@Req() req: any, @Body() dto: CreateGuestBookingDto) {
return this.guestService.createGuestBooking(dto, req);
}
@Get('saved-passengers')

View File

@@ -6,11 +6,12 @@ import { GuestBookingService } from './guest-booking.service';
import { SeatsModule } from '../seats/seats.module';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]
@Module({
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule, AuthModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]
})
export class BookingsModule {}

View File

@@ -3,10 +3,10 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { PassengerAuthService } from '../auth/passenger-auth.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import * as bcrypt from 'bcrypt';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
@@ -28,10 +28,11 @@ export class GuestBookingService {
private seatsService: SeatsService,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
private passengerAuthService: PassengerAuthService,
private eventEmitter: EventEmitter2,
) {}
async createGuestBooking(dto: CreateGuestBookingDto) {
async createGuestBooking(dto: CreateGuestBookingDto, req: any) {
// Validate hold
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) {
@@ -71,15 +72,12 @@ export class GuestBookingService {
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
// Determine if passenger is Ethiopian
const isEthiopian = passenger.nationality === 'Ethiopian' ||
const isEthiopian = passenger.nationality === 'Ethiopian' ||
passenger.nationality === 'ETHIOPIAN' ||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
// 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(
@@ -91,22 +89,14 @@ export class GuestBookingService {
verifaydaData = verification.passengerData?.profileData;
}
nationality = 'Ethiopian';
}
// International passenger with Passport (non-Ethiopian)
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Passport details are required for international passengers
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) {
throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
}
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
// Ethiopian with Passport (manual entry without Fayda)
else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Ethiopians can use passport instead of national ID
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
nationality = 'Ethiopian';
}
// International with National ID (e.g., Djiboutian national ID)
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
nationality = nationality || 'Other';
}
@@ -155,85 +145,57 @@ export class GuestBookingService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
// Create or get guest passenger
// Resolve or create the guest Passenger record
const firstPassenger = passengersData[0];
let guestPassenger = null;
let userId = null;
let guestPassengerId: string;
let iamUserId: string | null = null;
let createdAccount = false;
// Optional account creation
if (dto.createAccount && firstPassenger.email && dto.password) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) {
throw new BadRequestException('Email already registered. Please login instead.');
}
let accountPhone = firstPassenger.phone || null;
if (accountPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
}
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: {
// Delegate full IAM account creation to PassengerAuthService
const result = await this.passengerAuthService.register(
{
fullName: firstPassenger.passengerName,
email: firstPassenger.email,
phone: accountPhone,
passwordHash,
phone: firstPassenger.phone || `+guest-${Date.now()}`,
password: dto.password,
nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
passportNumber: firstPassenger.passportNumber,
},
});
guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
userId = user.id;
req,
);
guestPassengerId = result.user.passengerId;
iamUserId = result.user.iamUserId;
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`;
}
}
// Use a guaranteed-unique guest phone to avoid constraint collisions
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existingPhone) guestPhone = null;
}
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
const tempUser = await this.prisma.user.create({
// Anonymous guest — Passenger with no User, no IAM account
const guestPassenger = await this.prisma.passenger.create({
data: {
fullName: firstPassenger.passengerName,
email: guestEmail,
phone: guestPhone,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
// userId intentionally omitted — guest has no local User or IAM account
...(dto.deviceId ? {} : {}),
},
});
guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
guestPassengerId = guestPassenger.id;
}
// Save passenger details for future use (if requested)
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
for (const passenger of passengersData) {
// Note: SavedPassengerProfile will be available after migration
// Temporarily disabled until prisma generate completes
// await this.prisma.savedPassengerProfile.create({ ... });
await this.prisma.savedPassengerProfile.create({
data: {
userId: iamUserId ?? undefined,
deviceId: dto.deviceId,
passengerName: passenger.passengerName,
dateOfBirth: passenger.dateOfBirth,
idDocumentType: passenger.idDocumentType,
passportNumber: passenger.passportNumber,
passportCountry: passenger.passportCountry,
nationality: passenger.nationality,
phone: passenger.phone,
email: passenger.email,
},
});
}
}
@@ -241,7 +203,7 @@ export class GuestBookingService {
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: guestPassenger.id,
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
totalMinor,
@@ -251,8 +213,6 @@ export class GuestBookingService {
displayTotalMinor,
bookingType: 'ONE_WAY',
userAgent: dto.deviceId,
// contactEmail: firstPassenger.email, // Temporarily disabled until migration
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration
seats: {
create: passengersData.map((p) => ({
seat: { connect: { id: p.seatId } },
@@ -282,7 +242,7 @@ export class GuestBookingService {
return {
...booking,
createdAccount,
userId,
iamUserId,
fareBreakdown: {
baseFareMinor,
adultCount,
@@ -307,10 +267,6 @@ export class GuestBookingService {
throw new BadRequestException('Either userId or deviceId is required');
}
// Temporarily return empty array until Prisma client is regenerated
return [];
/* Uncomment after running migration and prisma generate
const profiles = await this.prisma.savedPassengerProfile.findMany({
where: {
OR: [
@@ -325,14 +281,13 @@ export class GuestBookingService {
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth.toISOString().split('T')[0],
idDocumentType: p.idDocumentType,
idDocumentNumber: undefined, // Never return sensitive data
idDocumentNumber: undefined,
passportNumber: p.passportNumber || undefined,
passportCountry: p.passportCountry || undefined,
nationality: p.nationality || undefined,
phone: p.phone || undefined,
email: p.email || undefined,
}));
*/
}
private async getBaseFare(
@@ -376,6 +331,6 @@ export class GuestBookingService {
if (match) return match.baseFareMinor;
}
return 35000; // Default fallback
return 35000;
}
}

View File

@@ -27,7 +27,7 @@ export class DashboardService {
const hour = now.getHours();
const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING';
const firstName = passenger?.user.fullName.split(' ')[0] ?? '';
const firstName = passenger?.user?.fullName?.split(' ')[0] ?? '';
const seat = upcomingBooking?.seats[0];
return {

View File

@@ -71,12 +71,12 @@ export class PassengersService {
return {
items: items.map(passenger => ({
id: passenger.id,
fullName: passenger.user.fullName,
email: passenger.user.email,
phone: passenger.user.phone,
nationalId: passenger.user.nationalId,
nationality: passenger.user.nationality,
verified: !!passenger.user.nationalId,
fullName: passenger.user?.fullName ?? null,
email: passenger.user?.email ?? null,
phone: passenger.user?.phone ?? null,
nationalId: passenger.user?.nationalId ?? null,
nationality: passenger.user?.nationality ?? null,
verified: !!passenger.user?.nationalId,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
@@ -103,9 +103,9 @@ export class PassengersService {
if (!p) throw new NotFoundException('Passenger not found');
return {
id: p.id,
fullName: p.user.fullName,
email: p.user.email,
phone: p.user.phone,
fullName: p.user?.fullName ?? null,
email: p.user?.email ?? null,
phone: p.user?.phone ?? null,
createdAt: p.createdAt,
bookings: p.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,

View File

@@ -507,9 +507,8 @@ export class VerifaydaService {
): Promise<VerifaydaVerificationResult> {
this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`);
if (this.stubEnabled != false || this.stubEnabled) {
this.logger.warn('Verifayda stub is disabled - returning mock data (development mode)');
// In development mode, return mock verified data
if (!this.stubEnabled) {
this.logger.warn('Verifayda not configured returning mock data (development mode)');
return {
verified: true,
passengerData: {