import { Injectable, ConflictException, InternalServerErrorException, Logger, UnauthorizedException, } from '@nestjs/common'; import { ModuleRef, ContextIdFactory } from '@nestjs/core'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service'; import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum'; import { PrismaService } from '../../common/prisma.service'; import { RegisterDto, LoginDto } from './auth.dto'; type IamUserRow = { id: string; email: string; name: { en: string; am: string } | null; phone_number: string | null; metadata: Record | null; verified_by: string | null; }; function resolvePreferredCurrency(nationality: string | null | undefined, faydaVerified: boolean): string { if (faydaVerified) return 'ETB'; const n = (nationality ?? '').toLowerCase(); if (n.includes('ethiopi')) return 'ETB'; if (n.includes('djibout')) return 'DJF'; return 'USD'; } @Injectable() export class PassengerAuthService { private readonly logger = new Logger(PassengerAuthService.name); constructor( private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, private readonly moduleRef: ModuleRef, private readonly eventEmitter: EventEmitter2, ) {} private async resolveIamAuthService(req: any): Promise { const contextId = ContextIdFactory.getByRequest(req); this.moduleRef.registerRequestByContextId(req, contextId); return this.moduleRef.resolve(IamAuthService, contextId, { strict: false }); } async register(dto: RegisterDto, req: any) { await this.clearPendingOrConflict(dto.email, dto.phoneNumber); const iamAuthService = await this.resolveIamAuthService(req); // IAM `signup` creates the user as PENDING/isActive=false with NO credential and // SMS-sends a 6-digit verification code. The account cannot log in until the code is // redeemed via PATCH /v1/auth/set-password. We intentionally discard the session // token `signup` returns — the account is not verified yet, so it must never reach // the client. await iamAuthService.signup({ email: dto.email, username: dto.username, phoneNumber: dto.phoneNumber, userType: EUserType.INDIVIDUAL, name: dto.name, }); const iamRows = await this.dataSource.query( `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, [dto.email], ); if (!iamRows.length) { await this.compensateIamSignup(dto.email); throw new InternalServerErrorException('Account creation failed. Please try again.'); } const iamUserId = iamRows[0].id; // The Prisma "passenger satellite" (Passenger + wallet + loyalty) is NOT provisioned // here — `login()` lazy-provisions it on first successful login, so satellites exist // only for verified users who complete set-password and sign in. return { iamUserId, email: dto.email, phoneNumber: dto.phoneNumber, requiresPasswordSetup: true, }; } /** * Immediate-activation account creation used by the payment-gated guest-checkout * "create account" path only. Unlike the public `register()` (OTP-gated), this creates a * ready-to-use account from the password entered at checkout and provisions the passenger * satellite synchronously so the booking can attach to it. Do NOT wire this to the public * registration form — that flow must stay behind SMS verification. */ async registerWithPassword( dto: { email: string; username: string; phoneNumber: string; name: { en: string; am: string }; password: string; }, req: any, ): Promise<{ iamUserId: string; passengerId: string }> { await this.clearPendingOrConflict(dto.email, dto.phoneNumber); const iamAuthService = await this.resolveIamAuthService(req); await iamAuthService.signupWithPassword({ email: dto.email, username: dto.username, phoneNumber: dto.phoneNumber, userType: EUserType.INDIVIDUAL, name: dto.name, password: dto.password, confirmPassword: dto.password, }); const iamRows = await this.dataSource.query( `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, [dto.email], ); if (!iamRows.length) { await this.compensateIamSignup(dto.email); throw new InternalServerErrorException('Account creation failed. Please try again.'); } const iamUserId = iamRows[0].id; try { const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' }); return { iamUserId, passengerId: result.passengerId }; } catch { await this.compensateIamSignup(dto.email); throw new InternalServerErrorException('Account creation failed. Please try again.'); } } async resendRegistrationCode( dto: { email: string; phoneNumber: string }, req: any, ): Promise<{ sent: boolean }> { // Only regenerate for accounts still pending password setup. A fully-registered user // should use forgot-password instead. Always return { sent: true } to avoid leaking // whether the email/phone maps to a pending account (enumeration guard). const users = await this.dataSource.query<{ email: string; phone_number: string }[]>( `SELECT email, phone_number FROM iam.users WHERE email = $1 AND phone_number = $2 AND has_set_password = false LIMIT 1`, [dto.email, dto.phoneNumber], ); if (!users.length) return { sent: true }; const iamAuthService = await this.resolveIamAuthService(req); try { await iamAuthService.generateVerificationCode({ email: users[0].email, phoneNumber: users[0].phone_number, type: EOtpType.VERIFY_PHONE_NUMBER, }); } catch (err) { this.logger.error( `[PassengerAuthService] resend registration code failed for ${dto.email}`, (err as Error).message, ); } return { sent: true }; } async login(dto: LoginDto, req: any) { const iamAuthService = await this.resolveIamAuthService(req); let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean }; try { iamResult = await iamAuthService.login({ email: dto.email, password: dto.password }); } catch { this.eventEmitter.emit('auth.login.failed', { email: dto.email }); throw new UnauthorizedException('Invalid credentials'); } if ('mfaRequired' in iamResult && iamResult.mfaRequired) { return iamResult; } const { token, refreshToken } = iamResult as { token: string; refreshToken: string }; // `dto.email` may hold an email OR a phone number (passengers without an email log in // with their phone). Match on either so the post-auth lookup works regardless of which // identifier was used. const iamRows = await this.dataSource.query( `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`, [dto.email], ); const iamUser = iamRows[0]; if (!iamUser) { throw new InternalServerErrorException('IAM user not found after successful authentication'); } // Find existing Passenger record or lazy-provision one on first login let passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: iamUser.id }, select: { id: true }, }); if (!passenger) { const result = await this.provisionPassengerSatellite({ iamUserId: iamUser.id, auditAction: 'USER_AUTO_PROVISIONED', }); passenger = { id: result.passengerId }; } return { token, refreshToken, user: { id: iamUser.id, iamUserId: iamUser.id, email: iamUser.email, passengerId: passenger.id }, }; } private async provisionPassengerSatellite(data: { iamUserId: string; auditAction: string; }): Promise<{ passengerId: string }> { return this.prisma.$transaction(async (tx) => { const passenger = await tx.passenger.create({ data: { iamUserId: data.iamUserId }, }); await tx.loyaltyAccount.create({ data: { passengerId: passenger.id } }); await tx.walletAccount.create({ data: { passengerId: passenger.id } }); await tx.userPreferences.create({ data: { iamUserId: data.iamUserId } }); await tx.auditLog.create({ data: { iamUserId: data.iamUserId, action: data.auditAction, entityType: 'User', entityId: data.iamUserId, newData: { iamUserId: data.iamUserId }, }, }); return { passengerId: passenger.id }; }); } async logout(user: any, req: any) { const iamAuthService = await this.resolveIamAuthService(req); await iamAuthService.logout(user); return { success: true, message: 'Logged out successfully' }; } async getProfile(iamUserId: string) { 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, verified_by FROM iam.users WHERE id = $1 LIMIT 1`, [iamUserId], ), ]); if (!passenger) throw new Error('Passenger not found'); const iam = iamRows[0]; const meta = iam?.metadata ?? {}; const faydaVerified = iam?.verified_by === 'fayda'; // A Fayda-verified holder is an Ethiopian national ID holder, so default nationality to // Ethiopian when the metadata doesn't carry it explicitly. const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null); // Fayda stores gender as { am, en }; tolerate a legacy plain string too. const gender = meta.gender && typeof meta.gender === 'object' ? (meta.gender.en ?? meta.gender.am ?? null) : (meta.gender ?? null); // birthdate is persisted as ISO by the Fayda upsert; tolerate a "/"-separated legacy value. const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null; const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null; return { // The web User object keys on `id` (the IAM user id) — the login response returns it, so // this profile refresh MUST too, otherwise fetchProfile() overwrites the logged-in user // with an id-less object and everything guarded on `user.id` (passenger-form prefill, // save-details userId) silently breaks. id: iamUserId, iamUserId, // Top-level passengerId keeps the profile shape consistent with the login // response so the web User object always carries it (the JWT does not). passengerId: passenger.id, email: iam?.email ?? null, phone: iam?.phone_number ?? null, fullName: iam?.name?.en ?? iam?.name?.am ?? null, gender, dateOfBirth, nationality, faydaVerified, faydaSub: meta.sub ?? null, preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified), createdAt: passenger.createdAt, passenger: { id: passenger.id, preferredLanguage: passenger.preferredLanguage, loyalty: passenger.loyalty ? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: passenger.loyalty.lifetimePoints } : null, wallet: passenger.wallet ? { balanceMinor: passenger.wallet.balanceMinor, currency: passenger.wallet.currency } : null, }, }; } async listUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) { const page = filters.page ?? 1; const pageSize = filters.pageSize ?? 20; const offset = (page - 1) * pageSize; const params: any[] = []; const conditions: string[] = []; if (filters.search) { params.push(`%${filters.search}%`); conditions.push(`(u.email ILIKE $${params.length} OR (u.name->>'en') ILIKE $${params.length})`); } if (filters.role) { params.push(`%${filters.role}%`); conditions.push(`r.key ILIKE $${params.length}`); } if (filters.status) { const active = filters.status === 'ACTIVE'; params.push(active); conditions.push(`u.is_active = $${params.length}`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const baseQuery = ` FROM iam.users u LEFT JOIN iam.user_roles ur ON ur.user_id = u.id LEFT JOIN iam.roles r ON r.id = ur.role_id ${where} `; const countParams = [...params]; const [rows, countRows] = await Promise.all([ this.dataSource.query( `SELECT DISTINCT u.id, u.email, u.name, u.phone_number, u.is_active, u.status, u.created_at, r.key as role_key, r.name as role_name ${baseQuery} ORDER BY u.created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, pageSize, offset], ), this.dataSource.query( `SELECT COUNT(DISTINCT u.id) as count ${baseQuery}`, countParams, ), ]); const items = rows.map((u: any) => ({ id: u.id, email: u.email, fullName: u.name?.en ?? u.name?.am ?? '', role: u.role_key ?? '', status: u.is_active ? 'ACTIVE' : 'INACTIVE', lastLogin: u.metadata?.lastLogin ?? null, createdAt: u.created_at, })); return { items, total: parseInt(countRows[0]?.count ?? '0'), page, pageSize }; } async createUser(data: { email: string; fullName: string; role: string; password: string; status?: string }) { const existing = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, [data.email], ); if (existing.length) throw new ConflictException('Email already registered'); // Derive username from email local-part; ensure uniqueness by appending a short suffix if taken const baseUsername = data.email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, ''); const taken = await this.dataSource.query<{ username: string }[]>( `SELECT username FROM iam.users WHERE username LIKE $1 LIMIT 10`, [`${baseUsername}%`], ); const takenSet = new Set(taken.map((r) => r.username)); let username = baseUsername; let suffix = 1; while (takenSet.has(username)) { username = `${baseUsername}${suffix++}`; } // Hash with argon2 — same algorithm the IAM login uses (verifyPassword in auth.service.js) const { hashPassword } = await import('@tria-plc/api-common/utils/argon'); const passwordHash = await hashPassword(data.password); await this.dataSource.query( `INSERT INTO iam.users (email, username, name, user_type, status, is_active) VALUES ($1, $2, $3::jsonb, 'employee', $4, $5)`, [ data.email, username, JSON.stringify({ en: data.fullName, am: data.fullName }), data.status === 'INACTIVE' ? 'pending' : 'accepted', data.status !== 'INACTIVE', ], ); // Insert credential with correct column `password` and is_active = true // so the IAM login SQL (find-user-for-login.sql) can find and verify it const newUser = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, [data.email], ); if (newUser.length) { await this.dataSource.query( `UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`, [newUser[0].id], ); await this.dataSource.query( `INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`, [newUser[0].id, passwordHash], ); } // Assign the selected role in iam.user_roles const rows = await this.dataSource.query( `SELECT id, email, name, is_active, created_at FROM iam.users WHERE email = $1 LIMIT 1`, [data.email], ); const u = rows[0]; if (data.role && u) { try { const roleRows = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`, [data.role], ); if (roleRows.length) { await this.dataSource.query( `INSERT INTO iam.user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [u.id, roleRows[0].id], ); } } catch { // non-fatal — role assignment failure should not block user creation } } return { id: u.id, email: u.email, fullName: data.fullName, role: data.role, status: u.is_active ? 'ACTIVE' : 'INACTIVE', createdAt: u.created_at, }; } async updateUser(id: string, data: { fullName?: string; role?: string; status?: string }) { const rows = await this.dataSource.query( `SELECT id, name, is_active FROM iam.users WHERE id = $1 LIMIT 1`, [id], ); if (!rows.length) throw new ConflictException('User not found'); const existing = rows[0]; const name = data.fullName ? { en: data.fullName, am: data.fullName } : existing.name; const isActive = data.status ? data.status === 'ACTIVE' : existing.is_active; await this.dataSource.query( `UPDATE iam.users SET name = $1::jsonb, is_active = $2, updated_at = NOW() WHERE id = $3`, [JSON.stringify(name), isActive, id], ); // Update role: remove existing user_roles then assign the new one if (data.role) { try { const roleRows = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`, [data.role], ); if (roleRows.length) { await this.dataSource.query(`DELETE FROM iam.user_roles WHERE user_id = $1`, [id]); await this.dataSource.query( `INSERT INTO iam.user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [id, roleRows[0].id], ); } } catch { // non-fatal } } return { id, fullName: (name as any)?.en, role: data.role, status: isActive ? 'ACTIVE' : 'INACTIVE' }; } async deleteUser(id: string) { await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [id]); return { success: true }; } async resetUserPassword(id: string, tempPassword: string) { const { hashPassword } = await import('@tria-plc/api-common/utils/argon'); const passwordHash = await hashPassword(tempPassword); // Deactivate existing credentials first (IAM keeps history, only one active at a time) await this.dataSource.query( `UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`, [id], ); // Insert new active credential await this.dataSource.query( `INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`, [id, passwordHash], ); return { success: true, message: 'Password reset successfully' }; } async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> { const phone = this.standardizePhone(phoneNumber); const users = await this.dataSource.query<{ id: string; email: string }[]>( `SELECT id, email FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`, [phone], ); this.logger.log(`requestFaydaPasswordSetup: phone=${phone} found=${users.length > 0}`); // Return success regardless to avoid phone enumeration if (!users.length) return { sent: true }; const u = users[0]; const iamAuthService = await this.resolveIamAuthService(req); await iamAuthService.generateVerificationCode({ email: u.email, phoneNumber: phone, type: EOtpType.SET_PASSWORD, }); return { sent: true }; } async verifyFaydaAndLogin( phoneNumber: string, otp: string, ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }> { const phone = this.standardizePhone(phoneNumber); const users = await this.dataSource.query<{ id: string; email: string; name: { en: string; am: string } | null; username: string; phone_number: string | null; has_set_password: boolean; }[]>( `SELECT id, email, name, username, phone_number, has_set_password FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`, [phone], ); if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP'); const u = users[0]; const verifications = await this.dataSource.query<{ id: string; verification_code: string; attempt_count: number; }[]>( `SELECT id, verification_code, attempt_count FROM iam.user_verifications WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1`, [u.id], ); if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP'); const v = verifications[0]; if (v.attempt_count >= 5) { await this.dataSource.query( `UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id], ); throw new UnauthorizedException('Too many attempts. Request a new code.'); } await this.dataSource.query( `UPDATE iam.user_verifications SET attempt_count = attempt_count + 1 WHERE id = $1`, [v.id], ); const { verifyPassword } = await import('@tria-plc/api-common/utils/argon'); const valid = await verifyPassword(otp, v.verification_code); if (!valid) throw new UnauthorizedException('Invalid phone number or OTP'); await this.dataSource.query( `UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id], ); const userInfo = { id: u.id, email: u.email ?? '', name: u.name ?? { en: '', am: '' }, userType: 'individual', status: 'accepted', hasSetPassword: u.has_set_password, isPhoneNumberVerified: false, hasFinishedRegistration: false, hasFinishedDMSOnboarding: false, username: u.username, phoneNumber: u.phone_number ?? '', roles: [], permissions: [], employee: [], }; const sessions = await this.dataSource.query<{ id: string }[]>( `INSERT INTO iam.sessions (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) ON CONFLICT (user_id, device) DO UPDATE SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() RETURNING id`, [u.email ?? '', JSON.stringify(userInfo), u.id], ); const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token'); const token = generateToken({ id: sessions[0].id }); const refreshToken = generateRefreshToken({ id: sessions[0].id }); return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id }; } private standardizePhone(phone: string): string { const digits = phone.replace(/\D/g, ''); if (digits.startsWith('251')) return `+${digits}`; if (digits.startsWith('0')) return `+251${digits.slice(1)}`; return `+${digits}`; } /** * Pre-signup uniqueness guard. Throws `ConflictException` only when a * *fully-registered* account (`has_set_password = true`) already owns the * email or phone. Abandoned PENDING signups — where the user received the OTP * but never completed `set-password` — are deleted so this fresh attempt can * re-create the account and re-send the code, instead of being blocked with a * 409 forever. Matches `resendRegistrationCode`'s `has_set_password = false` * notion of "still pending". */ private async clearPendingOrConflict(email: string, phoneNumber: string): Promise { const matches = await this.dataSource.query< { id: string; email: string; has_set_password: boolean }[] >( `SELECT id, email, has_set_password FROM iam.users WHERE email = $1 OR phone_number = $2`, [email, phoneNumber], ); if (!matches.length) return; if (matches.some((u) => u.has_set_password)) { throw new ConflictException('Email or phone already registered'); } // Every match is an abandoned pending signup — clean it up so the caller can proceed. for (const u of matches) { await this.compensateIamSignup(u.email); } } private async compensateIamSignup(email: string): Promise { try { const rows = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, [email], ); if (!rows.length) return; const iamUserId = rows[0].id; // Discover every table in the iam schema that has a FK pointing at iam.users.id const fkDeps = await this.dataSource.query<{ table_name: string; column_name: string }[]>(` SELECT kcu.table_name, kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema JOIN information_schema.referential_constraints rc ON tc.constraint_name = rc.constraint_name JOIN information_schema.key_column_usage ccu ON rc.unique_constraint_name = ccu.constraint_name WHERE ccu.table_schema = 'iam' AND ccu.table_name = 'users' AND ccu.column_name = 'id' AND tc.table_schema = 'iam' AND tc.constraint_type = 'FOREIGN KEY' `); for (const { table_name, column_name } of fkDeps) { await this.dataSource.query( `DELETE FROM iam.${table_name} WHERE ${column_name} = $1`, [iamUserId], ); } await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [iamUserId]); } catch (err) { this.logger.error(`[PassengerAuthService] IAM compensating cleanup failed for ${email}`, (err as Error).message); } } }