refactor(iam): remove remaining prisma.user refs from passenger side align RegisterDto to IAM body

This commit is contained in:
Abubeker Yasin
2026-06-09 11:53:46 +03:00
parent 1d35453338
commit 5ae6d9600a
6 changed files with 296 additions and 374 deletions

View File

@@ -1,43 +1,43 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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' }) @ApiProperty({ example: 'Kelemu Ketsela' })
@IsString() @IsString()
fullName: string; en: string;
}
export class RegisterDto {
@ApiProperty({ example: 'kelemu@email.com' }) @ApiProperty({ example: 'kelemu@email.com' })
@IsEmail() @IsEmail()
email: string; email: string;
@ApiProperty({ example: 'kelemu.ketsela' })
@IsString()
username: string;
@ApiProperty({ example: '+251912345678' }) @ApiProperty({ example: '+251912345678' })
@IsString() @IsString()
phone: string; phoneNumber: string;
@ApiProperty({ type: NameDto })
@ValidateNested()
@Type(() => NameDto)
name: NameDto;
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' }) @ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
@IsString() @IsString()
@MinLength(8) @MinLength(8)
password: string; password: string;
@ApiPropertyOptional({ example: 'SecurePass123', format: 'password' }) @ApiProperty({ example: 'SecurePass123', format: 'password' })
@IsOptional()
@IsString() @IsString()
confirmPassword?: string; confirmPassword: string;
@ApiPropertyOptional({ example: 'Ethiopian' })
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({ example: 'ET123456789' })
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({ example: 'P1234567' })
@IsOptional()
@IsString()
passportNumber?: string;
} }
export class LoginDto { export class LoginDto {

View File

@@ -13,7 +13,13 @@ import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto } from './auth.dto'; 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<string, any> | null;
};
@Injectable() @Injectable()
export class PassengerAuthService { export class PassengerAuthService {
@@ -33,7 +39,7 @@ export class PassengerAuthService {
async register(dto: RegisterDto, req: any) { async register(dto: RegisterDto, req: any) {
const existing = await this.dataSource.query<{ id: string }[]>( const existing = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, `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'); if (existing.length) throw new ConflictException('Email or phone already registered');
@@ -41,16 +47,16 @@ export class PassengerAuthService {
const { token, refreshToken } = await iamAuthService.signupWithPassword({ const { token, refreshToken } = await iamAuthService.signupWithPassword({
email: dto.email, email: dto.email,
username: dto.email, username: dto.username,
phoneNumber: dto.phone, phoneNumber: dto.phoneNumber,
userType: EUserType.INDIVIDUAL, userType: EUserType.INDIVIDUAL,
name: { en: dto.fullName, am: dto.fullName }, name: dto.name,
password: dto.password, password: dto.password,
confirmPassword: dto.confirmPassword ?? dto.password, confirmPassword: dto.confirmPassword,
}); });
const iamRows = await this.dataSource.query<IamUserRow[]>( const iamRows = await this.dataSource.query<IamUserRow[]>(
`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], [dto.email],
); );
if (!iamRows.length) { if (!iamRows.length) {
@@ -61,16 +67,7 @@ export class PassengerAuthService {
let passengerId: string; let passengerId: string;
try { try {
const result = await this.provisionPassengerSatellite({ const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' });
iamUserId,
email: dto.email,
fullName: dto.fullName,
phone: dto.phone,
nationality: dto.nationality,
nationalId: dto.nationalId,
passportNumber: dto.passportNumber,
auditAction: 'USER_REGISTERED',
});
passengerId = result.passengerId; passengerId = result.passengerId;
} catch { } catch {
await this.compensateIamSignup(dto.email); await this.compensateIamSignup(dto.email);
@@ -80,7 +77,7 @@ export class PassengerAuthService {
return { return {
token, token,
refreshToken, 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 { token, refreshToken } = iamResult as { token: string; refreshToken: string };
const iamRows = await this.dataSource.query<IamUserRow[]>( const iamRows = await this.dataSource.query<IamUserRow[]>(
`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], [dto.email],
); );
const iamUser = iamRows[0]; const iamUser = iamRows[0];
@@ -117,12 +114,8 @@ export class PassengerAuthService {
}); });
if (!passenger) { if (!passenger) {
const fullName = iamUser.name?.en ?? iamUser.name?.am ?? dto.email;
const result = await this.provisionPassengerSatellite({ const result = await this.provisionPassengerSatellite({
iamUserId: iamUser.id, iamUserId: iamUser.id,
email: dto.email,
fullName,
phone: iamUser.phone_number ?? '',
auditAction: 'USER_AUTO_PROVISIONED', auditAction: 'USER_AUTO_PROVISIONED',
}); });
passenger = { id: result.passengerId }; passenger = { id: result.passengerId };
@@ -137,59 +130,11 @@ export class PassengerAuthService {
private async provisionPassengerSatellite(data: { private async provisionPassengerSatellite(data: {
iamUserId: string; iamUserId: string;
email: string;
fullName: string;
phone: string;
nationality?: string;
nationalId?: string;
passportNumber?: string;
auditAction: string; auditAction: string;
}): Promise<{ passengerId: string }> { }): Promise<{ passengerId: string }> {
return this.prisma.$transaction(async (tx) => { 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({ 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.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await tx.walletAccount.create({ data: { passengerId: passenger.id } }); await tx.walletAccount.create({ data: { passengerId: passenger.id } });
@@ -200,7 +145,7 @@ export class PassengerAuthService {
action: data.auditAction, action: data.auditAction,
entityType: 'User', entityType: 'User',
entityId: data.iamUserId, entityId: data.iamUserId,
newData: { email: data.email, iamUserId: data.iamUserId }, newData: { iamUserId: data.iamUserId },
}, },
}); });
return { passengerId: passenger.id }; return { passengerId: passenger.id };
@@ -214,26 +159,26 @@ export class PassengerAuthService {
} }
async getProfile(iamUserId: string) { async getProfile(iamUserId: string) {
const passenger = await this.prisma.passenger.findUnique({ const [passenger, iamRows] = await Promise.all([
where: { iamUserId }, this.prisma.passenger.findUnique({
include: { where: { iamUserId },
user: true, include: { loyalty: true, wallet: true },
loyalty: true, }),
wallet: true, this.dataSource.query<IamUserRow[]>(
}, `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
}); [iamUserId],
if (!passenger) { ),
throw new Error('Passenger not found'); ]);
}
if (!passenger) throw new Error('Passenger not found');
const iam = iamRows[0];
return { return {
iamUserId, iamUserId,
email: passenger.user?.email, email: iam?.email ?? null,
phone: passenger.user?.phone, phone: iam?.phone_number ?? null,
fullName: passenger.user?.fullName, fullName: iam?.name?.en ?? iam?.name?.am ?? null,
nationality: passenger.user?.nationality, faydaVerified: iam?.metadata?.faydaVerified ?? false,
nationalId: passenger.user?.nationalId,
passportNumber: passenger.user?.passportNumber,
faydaVerified: passenger.user?.faydaVerified,
createdAt: passenger.createdAt, createdAt: passenger.createdAt,
passenger: { passenger: {
id: passenger.id, id: passenger.id,

View File

@@ -153,13 +153,15 @@ export class GuestBookingService {
if (dto.createAccount && firstPassenger.email && dto.password) { if (dto.createAccount && firstPassenger.email && dto.password) {
// Delegate full IAM account creation to PassengerAuthService // Delegate full IAM account creation to PassengerAuthService
const guestName = firstPassenger.passengerName ?? 'Guest';
const result = await this.passengerAuthService.register( const result = await this.passengerAuthService.register(
{ {
fullName: firstPassenger.passengerName,
email: firstPassenger.email, email: firstPassenger.email,
phone: firstPassenger.phone || `+guest-${Date.now()}`, username: firstPassenger.email,
phoneNumber: firstPassenger.phone || `+251900000000`,
name: { en: guestName, am: guestName },
password: dto.password, password: dto.password,
nationality: firstPassenger.nationality, confirmPassword: dto.password,
}, },
req, req,
); );

View File

@@ -1,4 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto'; import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
@@ -10,36 +12,68 @@ interface PassengerFilters {
pageSize?: number; pageSize?: number;
} }
type IamUserRow = {
id: string;
email: string;
name: { en: string; am: string } | null;
phone_number: string | null;
metadata: Record<string, any> | null;
};
@Injectable() @Injectable()
export class PassengersService { export class PassengersService {
constructor( constructor(
private prisma: PrismaService, private readonly prisma: PrismaService,
private verifaydaService: VerifaydaService, @InjectDataSource() private readonly dataSource: DataSource,
private readonly verifaydaService: VerifaydaService,
) {} ) {}
async findAll(filters: PassengerFilters = {}) { async findAll(filters: PassengerFilters = {}) {
const { search, verified, page = 1, pageSize = 20 } = filters; const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; 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 = {}; const where: any = {};
if (iamUserIdFilter) {
if (search) { where.iamUserId = { in: iamUserIdFilter };
where.user = {
OR: [
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
],
};
} }
if (verified !== undefined) {
where.user = {
...where.user,
nationalId: verified ? { not: null } : null,
};
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.passenger.findMany({ this.prisma.passenger.findMany({
where, where,
@@ -47,41 +81,38 @@ export class PassengersService {
take: pageSize, take: pageSize,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
user: {
select: {
id: true,
fullName: true,
email: true,
phone: true,
nationalId: true,
nationality: true,
},
},
loyalty: true, loyalty: true,
_count: { _count: { select: { bookings: true } },
select: {
bookings: true,
},
},
}, },
}), }),
this.prisma.passenger.count({ where }), 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<IamUserRow[]>(
`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 { return {
items: items.map(passenger => ({ items: items.map(passenger => {
id: passenger.id, const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
fullName: passenger.user?.fullName ?? null, const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
email: passenger.user?.email ?? null, return {
phone: passenger.user?.phone ?? null, id: passenger.id,
nationalId: passenger.user?.nationalId ?? null, fullName: iam?.name?.en ?? iam?.name?.am ?? null,
nationality: passenger.user?.nationality ?? null, email: iam?.email ?? null,
verified: !!passenger.user?.nationalId, phone: iam?.phone_number ?? null,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE', verified: faydaVerified,
loyaltyPoints: passenger.loyalty?.pointsBalance || 0, loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
totalBookings: passenger._count.bookings, loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
createdAt: passenger.createdAt, totalBookings: passenger._count.bookings,
})), createdAt: passenger.createdAt,
};
}),
meta: { meta: {
page, page,
pageSize, pageSize,
@@ -92,33 +123,56 @@ export class PassengersService {
} }
async getProfile(passengerId: string) { async getProfile(passengerId: string) {
console.log("here");
const p = await this.prisma.passenger.findUnique({ const p = await this.prisma.passenger.findUnique({
where: { id: passengerId }, where: { id: passengerId },
include: { include: {
user: { select: { fullName: true, email: true, phone: true } }, bookings: {
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } }, orderBy: { createdAt: 'desc' },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true, 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'); if (!p) throw new NotFoundException('Passenger not found');
let iamUser: IamUserRow | null = null;
if (p.iamUserId) {
const rows = await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
[p.iamUserId],
);
iamUser = rows[0] ?? null;
}
return { return {
id: p.id, id: p.id,
fullName: p.user?.fullName ?? null, fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null,
email: p.user?.email ?? null, email: iamUser?.email ?? null,
phone: p.user?.phone ?? null, phone: iamUser?.phone_number ?? null,
createdAt: p.createdAt, createdAt: p.createdAt,
bookings: p.bookings.map((b) => ({ 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: { trip: {
number: b.schedule.train.number, 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 }, 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 }, 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, 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) { async updatePassenger(id: string, dto: any) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } }); const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found'); if (!passenger) throw new NotFoundException('Passenger not found');
return this.prisma.passenger.update({
where: { id }, if (passenger.iamUserId && (dto.fullName || dto.email || dto.phone)) {
data: { const updates: string[] = [];
user: { const params: any[] = [];
update: { let idx = 1;
fullName: dto.fullName || undefined,
email: dto.email || undefined, if (dto.fullName) {
phone: dto.phone || undefined, updates.push(`name = COALESCE(name, '{}') || jsonb_build_object('en', $${idx}::text, 'am', $${idx}::text)`);
nationality: dto.nationality || undefined, params.push(dto.fullName);
}, idx++;
}, }
}, if (dto.email) {
include: { updates.push(`email = $${idx}`);
user: { select: { fullName: true, email: true, phone: true, nationality: true } }, params.push(dto.email);
loyalty: true, 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<IamUserRow[]>(
`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) { async registerPassenger(dto: RegisterPassengerDto) {
@@ -210,7 +294,6 @@ export class PassengersService {
const isLoggedIn = !!dto.userId; const isLoggedIn = !!dto.userId;
let verifiedData: any = null; let verifiedData: any = null;
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
if (isEthiopian && dto.verifyWithFayda !== false) { if (isEthiopian && dto.verifyWithFayda !== false) {
try { try {
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!); const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
@@ -218,12 +301,10 @@ export class PassengersService {
verifiedData = verification.passengerData; verifiedData = verification.passengerData;
} }
} catch (error) { } catch (error) {
// If verification fails, continue with manual data
console.warn('Fayda verification failed, using manual data:', error); console.warn('Fayda verification failed, using manual data:', error);
} }
} }
// Use verified data if available, otherwise use provided data
const finalData = { const finalData = {
passengerName: verifiedData?.fullName || dto.passengerName, passengerName: verifiedData?.fullName || dto.passengerName,
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth), dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
@@ -233,7 +314,6 @@ export class PassengersService {
email: dto.email, email: dto.email,
}; };
// If logged in, link passenger
if (isLoggedIn) { if (isLoggedIn) {
const linkedPassenger = await this.prisma.passenger.findUnique({ const linkedPassenger = await this.prisma.passenger.findUnique({
where: { iamUserId: dto.userId }, where: { iamUserId: dto.userId },
@@ -254,7 +334,6 @@ export class PassengersService {
}; };
} }
// Guest user - save to SavedPassengerProfile
const profile = await this.prisma.savedPassengerProfile.create({ const profile = await this.prisma.savedPassengerProfile.create({
data: { data: {
deviceId: dto.deviceId, deviceId: dto.deviceId,
@@ -283,7 +362,7 @@ export class PassengersService {
async deletePassenger(id: string) { async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } }); const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found'); if (!passenger) throw new NotFoundException('Passenger not found');
await this.prisma.passenger.delete({ where: { id } }); await this.prisma.passenger.delete({ where: { id } });
return { deleted: true, passengerId: id }; return { deleted: true, passengerId: id };
} }
@@ -305,4 +384,4 @@ export class PassengersService {
affectedModules: usage, affectedModules: usage,
}; };
} }
} }

View File

@@ -15,12 +15,6 @@ function buildPrismaMock() {
bookingSeat: { bookingSeat: {
updateMany: jest.fn(), updateMany: jest.fn(),
}, },
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
passenger: { create: jest.fn() }, passenger: { create: jest.fn() },
loyaltyAccount: { create: jest.fn() }, loyaltyAccount: { create: jest.fn() },
walletAccount: { 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>): FaydaConfig { function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
return { return {
enabled: true, enabled: true,
@@ -58,6 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
describe('VerifaydaService (OIDC, client-callback)', () => { describe('VerifaydaService (OIDC, client-callback)', () => {
let prisma: ReturnType<typeof buildPrismaMock>; let prisma: ReturnType<typeof buildPrismaMock>;
let dataSource: ReturnType<typeof buildDataSourceMock>;
let service: VerifaydaService; let service: VerifaydaService;
let realPrivateJwk: JWK; let realPrivateJwk: JWK;
@@ -69,11 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
beforeEach(() => { beforeEach(() => {
prisma = buildPrismaMock(); prisma = buildPrismaMock();
dataSource = buildDataSourceMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] }); const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService( service = new VerifaydaService(
buildConfigService(cfg), buildConfigService(cfg),
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
{ query: jest.fn().mockResolvedValue([]) } as any, dataSource as any,
); );
(global as any).fetch = jest.fn(); (global as any).fetch = jest.fn();
}); });
@@ -127,7 +127,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
const disabledService = new VerifaydaService( const disabledService = new VerifaydaService(
buildConfigService(buildConfig({ enabled: false })), buildConfigService(buildConfig({ enabled: false })),
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
{ query: jest.fn().mockResolvedValue([]) } as any, buildDataSourceMock() as any,
); );
await expect( await expect(
disabledService.startVerification({ purpose: 'PURCHASE' }), disabledService.startVerification({ purpose: 'PURCHASE' }),
@@ -147,7 +147,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
status: 'PENDING', status: 'PENDING',
errorCode: null, errorCode: null,
errorDescription: null, errorDescription: null,
userId: null, iamUserId: null,
bookingId: null, bookingId: null,
expiresAt: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000),
...overrides, ...overrides,
@@ -210,7 +210,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
platform: 'WEB', platform: 'WEB',
saveToAccount: false, saveToAccount: false,
status: 'PENDING', status: 'PENDING',
userId: null, iamUserId: null,
bookingId: null, bookingId: null,
expiresAt: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000),
...overrides, ...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( prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }), pendingSession({ iamUserId: 'iam-user-1', saveToAccount: true }),
); );
prisma.user.findFirst.mockResolvedValue(null); // First dataSource.query = conflict check returns [] (no conflict)
prisma.user.update.mockResolvedValue({}); // Second dataSource.query = UPDATE call returns []
dataSource.query
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
prisma.faydaVerificationSession.update.mockResolvedValue({}); prisma.faydaVerificationSession.update.mockResolvedValue({});
mockFetchSequence( mockFetchSequence(
@@ -285,17 +288,24 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
}); });
expect(result.verified).toBe(true); expect(result.verified).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith({ // conflict check: SELECT id FROM iam.users WHERE metadata->>'faydaSub' = ...
where: { id: 'user-1' }, expect(dataSource.query).toHaveBeenCalledWith(
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }), 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( 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 }); prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence( mockFetchSequence(
@@ -310,7 +320,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
await expect( await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }), service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 409 }); ).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 () => { it('throws 502 when the token endpoint returns 4xx', async () => {
@@ -384,7 +395,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
platform: 'WEB', platform: 'WEB',
saveToAccount: false, saveToAccount: false,
status: 'PENDING', status: 'PENDING',
userId: null, iamUserId: null,
bookingId: null, bookingId: null,
expiresAt: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000),
...overrides, ...overrides,
@@ -411,131 +422,41 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); (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(() => { beforeEach(() => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession()); prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
}); });
it('creates a new user but rejects legacy local token issuance', async () => { it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', 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({});
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' }); 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 }); prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
await expect( await expect(
service.completeVerification({ code: 'c', state: 'state-login' }), service.completeVerification({ code: 'c', state: 'state-login' }),
).rejects.toMatchObject({ status: 409 }); ).rejects.toMatchObject({
expect(prisma.user.update).not.toHaveBeenCalled(); status: 401,
expect(prisma.user.create).not.toHaveBeenCalled(); 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', () => { describe('getVerificationStatus', () => {
it('returns verified=true when User row has the flag', async () => { it('returns verified=true when IAM user metadata has the flag', async () => {
prisma.user.findUnique.mockResolvedValue({ dataSource.query.mockResolvedValueOnce([{
faydaVerified: true, metadata: { faydaVerified: true, faydaVerifiedAt: '2026-01-01T00:00:00.000Z' },
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'), name: { en: 'Test User', am: 'ቴስት ዩዘር' },
fullName: 'Test User', }]);
}); const result = await service.getVerificationStatus('iam-user-1');
const result = await service.getVerificationStatus('user-1');
expect(result).toEqual({ expect(result).toEqual({
verified: true, verified: true,
verifiedAt: new Date('2026-01-01T00:00:00Z'), 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 () => { it('returns verified=false when IAM user is missing or unverified', async () => {
prisma.user.findUnique.mockResolvedValue(null); dataSource.query.mockResolvedValueOnce([]);
const result = await service.getVerificationStatus('user-x'); const result = await service.getVerificationStatus('iam-user-x');
expect(result).toEqual({ verified: false }); expect(result).toEqual({ verified: false });
}); });
}); });

View File

@@ -250,50 +250,25 @@ export class VerifaydaService {
} }
} }
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
private async issueLoginToken( private async issueLoginToken(
userId: string, _userId: string,
): Promise<{ token: string; user: FaydaUserSummary }> { ): 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({ throw new UnauthorizedException({
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
message: 'Fayda login tokens are issued by the IAM package auth endpoints.', message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
user: summary,
}); });
} }
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> { async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
const passenger = await this.prisma.passenger.findUnique({ const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>(
where: { iamUserId }, `SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`,
include: { user: { select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true } } }, [iamUserId],
}); );
return { const iam = rows[0] ?? null;
verified: passenger?.user?.faydaVerified ?? false, const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
verifiedAt: passenger?.user?.faydaVerifiedAt ?? undefined, const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined;
fullName: passenger?.user?.fullName ?? undefined, const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
}; return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
} }
// ========================================================================== // ==========================================================================