diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index a32601c25..e12c8cd06 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -1,43 +1,43 @@ -import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class NameDto { + @ApiProperty({ example: 'ቀለሙ ቀጸላ' }) + @IsString() + am: string; -export class RegisterDto { @ApiProperty({ example: 'Kelemu Ketsela' }) @IsString() - fullName: string; + en: string; +} +export class RegisterDto { @ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string; + @ApiProperty({ example: 'kelemu.ketsela' }) + @IsString() + username: string; + @ApiProperty({ example: '+251912345678' }) @IsString() - phone: string; + phoneNumber: string; + + @ApiProperty({ type: NameDto }) + @ValidateNested() + @Type(() => NameDto) + name: NameDto; @ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' }) @IsString() @MinLength(8) password: string; - @ApiPropertyOptional({ example: 'SecurePass123', format: 'password' }) - @IsOptional() + @ApiProperty({ example: 'SecurePass123', format: 'password' }) @IsString() - confirmPassword?: string; - - @ApiPropertyOptional({ example: 'Ethiopian' }) - @IsOptional() - @IsString() - nationality?: string; - - @ApiPropertyOptional({ example: 'ET123456789' }) - @IsOptional() - @IsString() - nationalId?: string; - - @ApiPropertyOptional({ example: 'P1234567' }) - @IsOptional() - @IsString() - passportNumber?: string; + confirmPassword: string; } export class LoginDto { diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 3e09d2fe2..a0cf0040a 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -13,7 +13,13 @@ import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; import { PrismaService } from '../../common/prisma.service'; import { RegisterDto, LoginDto } from './auth.dto'; -type IamUserRow = { id: string; name: { en: string; am: string } | null; phone_number: string | null }; +type IamUserRow = { + id: string; + email: string; + name: { en: string; am: string } | null; + phone_number: string | null; + metadata: Record | null; +}; @Injectable() export class PassengerAuthService { @@ -33,7 +39,7 @@ export class PassengerAuthService { async register(dto: RegisterDto, req: any) { const existing = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, - [dto.email, dto.phone], + [dto.email, dto.phoneNumber], ); if (existing.length) throw new ConflictException('Email or phone already registered'); @@ -41,16 +47,16 @@ export class PassengerAuthService { const { token, refreshToken } = await iamAuthService.signupWithPassword({ email: dto.email, - username: dto.email, - phoneNumber: dto.phone, + username: dto.username, + phoneNumber: dto.phoneNumber, userType: EUserType.INDIVIDUAL, - name: { en: dto.fullName, am: dto.fullName }, + name: dto.name, password: dto.password, - confirmPassword: dto.confirmPassword ?? dto.password, + confirmPassword: dto.confirmPassword, }); const iamRows = await this.dataSource.query( - `SELECT id, name, phone_number FROM iam.users WHERE email = $1 LIMIT 1`, + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, [dto.email], ); if (!iamRows.length) { @@ -61,16 +67,7 @@ export class PassengerAuthService { let passengerId: string; try { - const result = await this.provisionPassengerSatellite({ - iamUserId, - email: dto.email, - fullName: dto.fullName, - phone: dto.phone, - nationality: dto.nationality, - nationalId: dto.nationalId, - passportNumber: dto.passportNumber, - auditAction: 'USER_REGISTERED', - }); + const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' }); passengerId = result.passengerId; } catch { await this.compensateIamSignup(dto.email); @@ -80,7 +77,7 @@ export class PassengerAuthService { return { token, refreshToken, - user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.fullName, passengerId }, + user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId }, }; } @@ -102,7 +99,7 @@ export class PassengerAuthService { const { token, refreshToken } = iamResult as { token: string; refreshToken: string }; const iamRows = await this.dataSource.query( - `SELECT id, name, phone_number FROM iam.users WHERE email = $1 LIMIT 1`, + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, [dto.email], ); const iamUser = iamRows[0]; @@ -117,12 +114,8 @@ export class PassengerAuthService { }); if (!passenger) { - const fullName = iamUser.name?.en ?? iamUser.name?.am ?? dto.email; const result = await this.provisionPassengerSatellite({ iamUserId: iamUser.id, - email: dto.email, - fullName, - phone: iamUser.phone_number ?? '', auditAction: 'USER_AUTO_PROVISIONED', }); passenger = { id: result.passengerId }; @@ -137,59 +130,11 @@ export class PassengerAuthService { private async provisionPassengerSatellite(data: { iamUserId: string; - email: string; - fullName: string; - phone: string; - nationality?: string; - nationalId?: string; - passportNumber?: string; auditAction: string; }): Promise<{ passengerId: string }> { return this.prisma.$transaction(async (tx) => { - // Check if a local User already exists (pre-IAM registration) - const existingUser = await tx.user.findFirst({ - where: { OR: [{ email: data.email }, { phone: data.phone }] }, - select: { id: true }, - }); - - if (existingUser) { - // User already exists — find their Passenger and stamp iamUserId - const existingPassenger = await tx.passenger.findFirst({ - where: { userId: existingUser.id }, - select: { id: true }, - }); - - if (existingPassenger) { - await tx.passenger.update({ - where: { id: existingPassenger.id }, - data: { iamUserId: data.iamUserId }, - }); - return { passengerId: existingPassenger.id }; - } - - // User exists but no Passenger yet — create just the Passenger + sub-records - const passenger = await tx.passenger.create({ - data: { userId: existingUser.id, iamUserId: data.iamUserId }, - }); - await tx.loyaltyAccount.create({ data: { passengerId: passenger.id } }); - await tx.walletAccount.create({ data: { passengerId: passenger.id } }); - return { passengerId: passenger.id }; - } - - // Brand new user — create the full satellite set - const user = await tx.user.create({ - data: { - email: data.email, - phone: data.phone, - fullName: data.fullName, - passwordHash: 'IAM_MANAGED', - nationality: data.nationality, - nationalId: data.nationalId, - passportNumber: data.passportNumber, - }, - }); const passenger = await tx.passenger.create({ - data: { userId: user.id, iamUserId: data.iamUserId }, + data: { iamUserId: data.iamUserId }, }); await tx.loyaltyAccount.create({ data: { passengerId: passenger.id } }); await tx.walletAccount.create({ data: { passengerId: passenger.id } }); @@ -200,7 +145,7 @@ export class PassengerAuthService { action: data.auditAction, entityType: 'User', entityId: data.iamUserId, - newData: { email: data.email, iamUserId: data.iamUserId }, + newData: { iamUserId: data.iamUserId }, }, }); return { passengerId: passenger.id }; @@ -214,26 +159,26 @@ export class PassengerAuthService { } async getProfile(iamUserId: string) { - const passenger = await this.prisma.passenger.findUnique({ - where: { iamUserId }, - include: { - user: true, - loyalty: true, - wallet: true, - }, - }); - if (!passenger) { - throw new Error('Passenger not found'); - } + const [passenger, iamRows] = await Promise.all([ + this.prisma.passenger.findUnique({ + where: { iamUserId }, + include: { loyalty: true, wallet: true }, + }), + this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ), + ]); + + if (!passenger) throw new Error('Passenger not found'); + const iam = iamRows[0]; + return { iamUserId, - email: passenger.user?.email, - phone: passenger.user?.phone, - fullName: passenger.user?.fullName, - nationality: passenger.user?.nationality, - nationalId: passenger.user?.nationalId, - passportNumber: passenger.user?.passportNumber, - faydaVerified: passenger.user?.faydaVerified, + email: iam?.email ?? null, + phone: iam?.phone_number ?? null, + fullName: iam?.name?.en ?? iam?.name?.am ?? null, + faydaVerified: iam?.metadata?.faydaVerified ?? false, createdAt: passenger.createdAt, passenger: { id: passenger.id, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 96448aa80..08e056126 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -153,13 +153,15 @@ export class GuestBookingService { if (dto.createAccount && firstPassenger.email && dto.password) { // Delegate full IAM account creation to PassengerAuthService + const guestName = firstPassenger.passengerName ?? 'Guest'; const result = await this.passengerAuthService.register( { - fullName: firstPassenger.passengerName, email: firstPassenger.email, - phone: firstPassenger.phone || `+guest-${Date.now()}`, + username: firstPassenger.email, + phoneNumber: firstPassenger.phone || `+251900000000`, + name: { en: guestName, am: guestName }, password: dto.password, - nationality: firstPassenger.nationality, + confirmPassword: dto.password, }, req, ); diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 362d1ccfe..904c82718 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -1,4 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto'; import { VerifaydaService } from '../verifayda/verifayda.service'; @@ -10,36 +12,68 @@ interface PassengerFilters { pageSize?: number; } +type IamUserRow = { + id: string; + email: string; + name: { en: string; am: string } | null; + phone_number: string | null; + metadata: Record | null; +}; + @Injectable() export class PassengersService { constructor( - private prisma: PrismaService, - private verifaydaService: VerifaydaService, + private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + private readonly verifaydaService: VerifaydaService, ) {} async findAll(filters: PassengerFilters = {}) { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - + + let iamUserIdFilter: string[] | null = null; + + if (search || verified !== undefined) { + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (search) { + conditions.push(`( + u.email ILIKE $${idx} OR + u.phone_number ILIKE $${idx} OR + (u.name->>'en') ILIKE $${idx} OR + (u.name->>'am') ILIKE $${idx} + )`); + params.push(`%${search}%`); + idx++; + } + + if (verified !== undefined) { + if (verified) { + conditions.push(`u.metadata->>'faydaVerified' = 'true'`); + } else { + conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`); + } + } + + const rows = await this.dataSource.query<{ id: string }[]>( + `SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`, + params, + ); + iamUserIdFilter = rows.map(r => r.id); + + if (iamUserIdFilter.length === 0) { + return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; + } + } + const where: any = {}; - - if (search) { - where.user = { - OR: [ - { fullName: { contains: search, mode: 'insensitive' } }, - { email: { contains: search, mode: 'insensitive' } }, - { phone: { contains: search, mode: 'insensitive' } }, - ], - }; + if (iamUserIdFilter) { + where.iamUserId = { in: iamUserIdFilter }; } - - if (verified !== undefined) { - where.user = { - ...where.user, - nationalId: verified ? { not: null } : null, - }; - } - + const [items, total] = await Promise.all([ this.prisma.passenger.findMany({ where, @@ -47,41 +81,38 @@ export class PassengersService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - user: { - select: { - id: true, - fullName: true, - email: true, - phone: true, - nationalId: true, - nationality: true, - }, - }, loyalty: true, - _count: { - select: { - bookings: true, - }, - }, + _count: { select: { bookings: true } }, }, }), this.prisma.passenger.count({ where }), ]); - + + const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + return { - items: items.map(passenger => ({ - id: passenger.id, - 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, - createdAt: passenger.createdAt, - })), + items: items.map(passenger => { + const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; + const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; + return { + id: passenger.id, + fullName: iam?.name?.en ?? iam?.name?.am ?? null, + email: iam?.email ?? null, + phone: iam?.phone_number ?? null, + verified: faydaVerified, + loyaltyTier: passenger.loyalty?.tier || 'BRONZE', + loyaltyPoints: passenger.loyalty?.pointsBalance || 0, + totalBookings: passenger._count.bookings, + createdAt: passenger.createdAt, + }; + }), meta: { page, pageSize, @@ -92,33 +123,56 @@ export class PassengersService { } async getProfile(passengerId: string) { - - console.log("here"); - const p = await this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { - user: { select: { fullName: true, email: true, phone: true } }, - bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } }, - loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true, + bookings: { + orderBy: { createdAt: 'desc' }, + take: 10, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } }, + }, + }, + loyalty: true, + wallet: true, + travelerProfiles: true, + savedRoutes: true, }, }); if (!p) throw new NotFoundException('Passenger not found'); + + let iamUser: IamUserRow | null = null; + if (p.iamUserId) { + const rows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [p.iamUserId], + ); + iamUser = rows[0] ?? null; + } + return { id: p.id, - fullName: p.user?.fullName ?? null, - email: p.user?.email ?? null, - phone: p.user?.phone ?? null, + fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, + email: iamUser?.email ?? null, + phone: iamUser?.phone_number ?? 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, + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalFare: b.totalMinor / 100, + createdAt: b.createdAt, trip: { number: b.schedule.train.number, origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city }, destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city }, departureAt: b.schedule.departureAt, }, - passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' } })), + passengers: b.seats.map((bs) => ({ + fullName: bs.passengerName, + seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' }, + })), })), }; } @@ -186,23 +240,53 @@ export class PassengersService { async updatePassenger(id: string, dto: any) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - return this.prisma.passenger.update({ - where: { id }, - data: { - user: { - update: { - fullName: dto.fullName || undefined, - email: dto.email || undefined, - phone: dto.phone || undefined, - nationality: dto.nationality || undefined, - }, - }, - }, - include: { - user: { select: { fullName: true, email: true, phone: true, nationality: true } }, - loyalty: true, - }, - }); + + if (passenger.iamUserId && (dto.fullName || dto.email || dto.phone)) { + const updates: string[] = []; + const params: any[] = []; + let idx = 1; + + if (dto.fullName) { + updates.push(`name = COALESCE(name, '{}') || jsonb_build_object('en', $${idx}::text, 'am', $${idx}::text)`); + params.push(dto.fullName); + idx++; + } + if (dto.email) { + updates.push(`email = $${idx}`); + params.push(dto.email); + idx++; + } + if (dto.phone) { + updates.push(`phone_number = $${idx}`); + params.push(dto.phone); + idx++; + } + + params.push(passenger.iamUserId); + await this.dataSource.query( + `UPDATE iam.users SET ${updates.join(', ')} WHERE id = $${idx}`, + params, + ); + } + + const [updated, iamRows] = await Promise.all([ + this.prisma.passenger.findUnique({ where: { id }, include: { loyalty: true } }), + passenger.iamUserId + ? this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [passenger.iamUserId], + ) + : Promise.resolve([] as IamUserRow[]), + ]); + + const iamUser = iamRows[0] ?? null; + return { + id: updated!.id, + fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, + email: iamUser?.email ?? null, + phone: iamUser?.phone_number ?? null, + loyalty: updated!.loyalty, + }; } async registerPassenger(dto: RegisterPassengerDto) { @@ -210,7 +294,6 @@ export class PassengersService { 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!); @@ -218,12 +301,10 @@ export class PassengersService { 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), @@ -233,7 +314,6 @@ export class PassengersService { email: dto.email, }; - // If logged in, link passenger if (isLoggedIn) { const linkedPassenger = await this.prisma.passenger.findUnique({ where: { iamUserId: dto.userId }, @@ -254,7 +334,6 @@ export class PassengersService { }; } - // Guest user - save to SavedPassengerProfile const profile = await this.prisma.savedPassengerProfile.create({ data: { deviceId: dto.deviceId, @@ -283,7 +362,7 @@ export class PassengersService { async deletePassenger(id: string) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - + await this.prisma.passenger.delete({ where: { id } }); return { deleted: true, passengerId: id }; } @@ -305,4 +384,4 @@ export class PassengersService { affectedModules: usage, }; } -} \ No newline at end of file +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts index d3b2da044..da234d22c 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -15,12 +15,6 @@ function buildPrismaMock() { bookingSeat: { updateMany: jest.fn(), }, - user: { - findUnique: jest.fn(), - findFirst: jest.fn(), - create: jest.fn(), - update: jest.fn(), - }, passenger: { create: jest.fn() }, loyaltyAccount: { create: jest.fn() }, walletAccount: { create: jest.fn() }, @@ -29,6 +23,10 @@ function buildPrismaMock() { }; } +function buildDataSourceMock() { + return { query: jest.fn().mockResolvedValue([]) }; +} + function buildConfig(overrides?: Partial): FaydaConfig { return { enabled: true, @@ -58,6 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked { let prisma: ReturnType; + let dataSource: ReturnType; let service: VerifaydaService; let realPrivateJwk: JWK; @@ -69,11 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { beforeEach(() => { prisma = buildPrismaMock(); + dataSource = buildDataSourceMock(); const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] }); service = new VerifaydaService( buildConfigService(cfg), prisma as unknown as PrismaService, - { query: jest.fn().mockResolvedValue([]) } as any, + dataSource as any, ); (global as any).fetch = jest.fn(); }); @@ -127,7 +127,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => { const disabledService = new VerifaydaService( buildConfigService(buildConfig({ enabled: false })), prisma as unknown as PrismaService, - { query: jest.fn().mockResolvedValue([]) } as any, + buildDataSourceMock() as any, ); await expect( disabledService.startVerification({ purpose: 'PURCHASE' }), @@ -147,7 +147,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => { status: 'PENDING', errorCode: null, errorDescription: null, - userId: null, + iamUserId: null, bookingId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, @@ -210,7 +210,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => { platform: 'WEB', saveToAccount: false, status: 'PENDING', - userId: null, + iamUserId: null, bookingId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, @@ -262,12 +262,15 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); }); - it('saves to the User account when saveToAccount=true and no conflict', async () => { + it('saves to the IAM user account when saveToAccount=true and no conflict', async () => { prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ userId: 'user-1', saveToAccount: true }), + pendingSession({ iamUserId: 'iam-user-1', saveToAccount: true }), ); - prisma.user.findFirst.mockResolvedValue(null); - prisma.user.update.mockResolvedValue({}); + // First dataSource.query = conflict check returns [] (no conflict) + // Second dataSource.query = UPDATE call returns [] + dataSource.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); prisma.faydaVerificationSession.update.mockResolvedValue({}); mockFetchSequence( @@ -285,17 +288,24 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); expect(result.verified).toBe(true); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }), - }); + // conflict check: SELECT id FROM iam.users WHERE metadata->>'faydaSub' = ... + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining(`metadata->>'faydaSub'`), + ['fayda-sub-2', 'iam-user-1'], + ); + // update: SET metadata = COALESCE(metadata, '{}') || ... + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE iam.users SET metadata'), + expect.arrayContaining(['iam-user-1']), + ); }); - it('throws identity_conflict (409) when faydaSub belongs to another user', async () => { + it('throws identity_conflict (409) when faydaSub belongs to another IAM user', async () => { prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ userId: 'user-1', saveToAccount: true }), + pendingSession({ iamUserId: 'iam-user-1', saveToAccount: true }), ); - prisma.user.findFirst.mockResolvedValue({ id: 'other-user' }); + // conflict check returns a conflicting row + dataSource.query.mockResolvedValueOnce([{ id: 'other-iam-user' }]); prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); mockFetchSequence( @@ -310,7 +320,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => { await expect( service.completeVerification({ code: 'authcode', state: 'state-abc' }), ).rejects.toMatchObject({ status: 409 }); - expect(prisma.user.update).not.toHaveBeenCalled(); + // UPDATE must not have been called + expect(dataSource.query).toHaveBeenCalledTimes(1); }); it('throws 502 when the token endpoint returns 4xx', async () => { @@ -384,7 +395,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => { platform: 'WEB', saveToAccount: false, status: 'PENDING', - userId: null, + iamUserId: null, bookingId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, @@ -411,131 +422,41 @@ describe('VerifaydaService (OIDC, client-callback)', () => { (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); } - /** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */ - function mockUserFindUnique(bySub: any, fullUser: any) { - prisma.user.findUnique.mockImplementation(async (args: any) => { - if (args?.where?.faydaSub !== undefined) return bySub; - if (args?.where?.id !== undefined) return fullUser; - return null; - }); - } - beforeEach(() => { prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession()); }); - it('creates a new user but rejects legacy local token issuance', async () => { - const fullUser = { - id: 'new-user', - email: 'new@example.com', - role: 'PASSENGER', - passenger: { id: 'p-new' }, - agent: null, - }; - mockUserFindUnique(null, fullUser); - prisma.user.findFirst.mockResolvedValue(null); - prisma.user.create.mockResolvedValue({ id: 'new-user' }); - prisma.passenger.create.mockResolvedValue({ id: 'p-new' }); - prisma.loyaltyAccount.create.mockResolvedValue({}); - prisma.walletAccount.create.mockResolvedValue({}); - prisma.userPreferences.create.mockResolvedValue({}); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - + it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', async () => { mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' }); - - await expect( - service.completeVerification({ - code: 'c', - state: 'state-login', - }), - ).rejects.toMatchObject({ status: 401 }); - expect(prisma.user.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - faydaSub: 'login-sub-1', - faydaVerified: true, - email: 'new@example.com', - }), - }), - ); - expect(prisma.passenger.create).toHaveBeenCalled(); - }); - - it('resolves an existing linked user but rejects legacy local token issuance', async () => { - const fullUser = { - id: 'known-user', - email: 'k@example.com', - role: 'PASSENGER', - passenger: { id: 'p-k' }, - agent: null, - }; - mockUserFindUnique({ id: 'known-user' }, fullUser); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - - mockLoginFetch({ sub: 'login-sub-2', name: 'Known' }); - - await expect( - service.completeVerification({ - code: 'c', - state: 'state-login', - }), - ).rejects.toMatchObject({ status: 401 }); - expect(prisma.user.create).not.toHaveBeenCalled(); - }); - - it('links Fayda to an existing account matched by email but rejects legacy local token issuance', async () => { - const fullUser = { - id: 'acc-1', - email: 'match@example.com', - role: 'PASSENGER', - passenger: { id: 'p-1' }, - agent: null, - }; - mockUserFindUnique(null, fullUser); - prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null }); - prisma.user.update.mockResolvedValue({}); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - - mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' }); - - await expect( - service.completeVerification({ - code: 'c', - state: 'state-login', - }), - ).rejects.toMatchObject({ status: 401 }); - expect(prisma.user.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'acc-1' }, - data: expect.objectContaining({ faydaSub: 'login-sub-3' }), - }), - ); - expect(prisma.user.create).not.toHaveBeenCalled(); - }); - - it('throws identity_conflict (409) when matched account has a different faydaSub', async () => { - mockUserFindUnique(null, null); - prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' }); prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); - mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' }); - await expect( service.completeVerification({ code: 'c', state: 'state-login' }), - ).rejects.toMatchObject({ status: 409 }); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(prisma.user.create).not.toHaveBeenCalled(); + ).rejects.toMatchObject({ + status: 401, + response: expect.objectContaining({ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM' }), + }); + }); + + it('does not touch the database for LOGIN purpose', async () => { + mockLoginFetch({ sub: 'login-sub-2', name: 'Person' }); + prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); + + await expect( + service.completeVerification({ code: 'c', state: 'state-login' }), + ).rejects.toMatchObject({ status: 401 }); + expect(dataSource.query).not.toHaveBeenCalled(); + expect(prisma.passenger.create).not.toHaveBeenCalled(); }); }); describe('getVerificationStatus', () => { - it('returns verified=true when User row has the flag', async () => { - prisma.user.findUnique.mockResolvedValue({ - faydaVerified: true, - faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'), - fullName: 'Test User', - }); - const result = await service.getVerificationStatus('user-1'); + it('returns verified=true when IAM user metadata has the flag', async () => { + dataSource.query.mockResolvedValueOnce([{ + metadata: { faydaVerified: true, faydaVerifiedAt: '2026-01-01T00:00:00.000Z' }, + name: { en: 'Test User', am: 'ቴስት ዩዘር' }, + }]); + const result = await service.getVerificationStatus('iam-user-1'); expect(result).toEqual({ verified: true, verifiedAt: new Date('2026-01-01T00:00:00Z'), @@ -543,9 +464,9 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); }); - it('returns verified=false when User row is missing or unverified', async () => { - prisma.user.findUnique.mockResolvedValue(null); - const result = await service.getVerificationStatus('user-x'); + it('returns verified=false when IAM user is missing or unverified', async () => { + dataSource.query.mockResolvedValueOnce([]); + const result = await service.getVerificationStatus('iam-user-x'); expect(result).toEqual({ verified: false }); }); }); diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index 09e5c20f7..c42a0ce0c 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -250,50 +250,25 @@ export class VerifaydaService { } } - /** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */ private async issueLoginToken( - userId: string, + _userId: string, ): Promise<{ token: string; user: FaydaUserSummary }> { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { passenger: true, agent: true }, - }); - if (!user) { - // Should not happen — we just resolved/created this user. - throw new UnauthorizedException({ - code: 'FAYDA_LOGIN_FAILED', - message: 'Could not load the verified user', - }); - } - - const summary: FaydaUserSummary = { - id: user.id, - email: user.email, - role: user.role, - passengerId: user.passenger?.id, - agentId: user.agent?.id, - }; - - this.logger.warn( - `Legacy passenger Fayda login reached for user ${user.id}; use IAM /v1/auth Fayda login to issue tokens.`, - ); throw new UnauthorizedException({ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', message: 'Fayda login tokens are issued by the IAM package auth endpoints.', - user: summary, }); } async getVerificationStatus(iamUserId: string): Promise { - const passenger = await this.prisma.passenger.findUnique({ - where: { iamUserId }, - include: { user: { select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true } } }, - }); - return { - verified: passenger?.user?.faydaVerified ?? false, - verifiedAt: passenger?.user?.faydaVerifiedAt ?? undefined, - fullName: passenger?.user?.fullName ?? undefined, - }; + const rows = await this.dataSource.query<{ metadata: Record | null; name: { en: string; am: string } | null }[]>( + `SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + const iam = rows[0] ?? null; + const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; + const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined; + const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; + return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; } // ==========================================================================