mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
691 lines
26 KiB
TypeScript
691 lines
26 KiB
TypeScript
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<string, any> | 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<IamAuthService> {
|
|
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<IamUserRow[]>(
|
|
`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<IamUserRow[]>(
|
|
`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<IamUserRow[]>(
|
|
`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<IamUserRow[]>(
|
|
`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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|
|
}
|