mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
refactor(iam): remove remaining prisma.user refs from passenger side align RegisterDto to IAM body
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user