import { BadRequestException, Injectable, NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; const RESERVED_ROLE_KEYS = new Set([ "super_admin", "organization_admin", "unit_admin", ]); const DEFAULT_USER_PASSWORD = "12345678"; const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin"; const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager"; @Injectable() export class BackofficeService { constructor( @InjectRepository(Employee) private readonly employeeRepository: Repository, @InjectRepository(Organization) private readonly organizationRepository: Repository, @InjectRepository(Role) private readonly roleRepository: Repository, @InjectRepository(UserRole) private readonly userRoleRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, private readonly dataSource: DataSource, ) {} async createOrganizationUser( organizationId: string, dto: CreateOrganizationUserDto, ) { const organizationExists = await this.organizationRepository.exists({ where: { id: organizationId }, }); if (!organizationExists) { throw new NotFoundException("organization_not_found"); } const email = dto.email.trim().toLowerCase(); const username = dto.username.trim().toLowerCase(); const phoneNumber = dto.phoneNumber?.trim() || undefined; const assignOrganizationAdmin = dto.assignOrganizationAdmin === true; const name = { en: dto.name.en.trim(), ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), }; const existingUsers = await this.userRepository.find({ where: [{ email }, { username }], select: { id: true, email: true, username: true }, }); const emailUser = existingUsers.find((user) => user.email === email); const usernameUser = existingUsers.find((user) => user.username === username); if (emailUser && usernameUser && emailUser.id !== usernameUser.id) { throw new BadRequestException("email_or_username_already_in_use"); } const existingUser = emailUser ?? usernameUser; const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD); return this.dataSource.transaction(async (manager) => { let user = existingUser; if (!user) { user = await manager.getRepository(User).save( manager.getRepository(User).create({ email, username, phoneNumber, name, isActive: true, hasSetPassword: true, status: EUserStatus.ACCEPTED, }), ); } else { await manager.getRepository(User).update( { id: user.id }, { email, username, phoneNumber, name, isActive: true, hasSetPassword: true, status: EUserStatus.ACCEPTED, }, ); } const activeCredentialExists = await manager.getRepository(UserCredential).exists({ where: { userId: user.id, isActive: true, }, }); if (!activeCredentialExists) { await manager.getRepository(UserCredential).insert({ userId: user.id, password: hashedPassword, isActive: true, }); } let employee = await manager.getRepository(Employee).findOne({ where: { userId: user.id, organizationId, isCurrent: true, }, relations: { user: true, employeePositions: { position: true, }, }, }); if (!employee) { const insertResult = await manager.getRepository(Employee).insert({ userId: user.id, organizationId, isCurrent: true, name, }); employee = await manager.getRepository(Employee).findOne({ where: { id: insertResult.identifiers[0]?.id as string }, relations: { user: true, employeePositions: { position: true, }, }, }); } else { await manager.getRepository(Employee).update( { id: employee.id }, { name }, ); employee = await manager.getRepository(Employee).findOne({ where: { id: employee.id }, relations: { user: true, employeePositions: { position: true, }, }, }); } if (!employee) { throw new NotFoundException("employee_create_failed"); } const userId = user.id; if (!userId) { throw new NotFoundException("user_create_failed"); } if (assignOrganizationAdmin) { await this.ensureOrganizationAdminAccess(manager, organizationId, userId); } return employee; }); } async getEmployeeUserRoles(organizationId: string, userId: string) { await this.assertUserBelongsToOrganization(organizationId, userId); const userRoles = await this.userRoleRepository.find({ where: { userId, organizationId, unitId: IsNull(), }, relations: { role: true, }, order: { role: { key: "ASC", }, }, }); return userRoles .map((userRole) => userRole.role) .filter((role): role is Role => Boolean(role)) .map((role) => ({ id: role.id, key: role.key, name: role.name, })); } async getOrganizationEmployees( organizationId: string, query: { skip?: string; take?: string }, ) { const organizationExists = await this.organizationRepository.exists({ where: { id: organizationId }, }); if (!organizationExists) { throw new NotFoundException("organization_not_found"); } const take = Number.parseInt(query.take ?? "1000", 10); const skip = Number.parseInt(query.skip ?? "0", 10); const employees = await this.employeeRepository.find({ where: { organizationId, isCurrent: true, }, relations: { user: true, employeePositions: { position: true, }, }, order: { createdAt: "DESC", }, }); const deduplicated = this.mergeEmployeesByUser(employees); return { count: deduplicated.length, items: deduplicated.slice(skip, skip + take), }; } async replaceEmployeeUserRoles( organizationId: string, userId: string, roleIds: string[], ) { await this.assertUserBelongsToOrganization(organizationId, userId); const uniqueRoleIds = [...new Set(roleIds)]; const roles = uniqueRoleIds.length ? await this.roleRepository.find({ where: { id: In(uniqueRoleIds), }, }) : []; if (roles.length !== uniqueRoleIds.length) { throw new NotFoundException("one_or_more_roles_not_found"); } const reservedRoles = roles.filter((role) => RESERVED_ROLE_KEYS.has(role.key)); if (reservedRoles.length) { throw new BadRequestException("reserved_roles_must_use_admin_actions"); } await this.dataSource.transaction(async (manager) => { await manager.getRepository(UserRole).delete({ userId, organizationId, unitId: IsNull(), }); if (!roles.length) { return; } await manager.getRepository(UserRole).insert( roles.map((role) => ({ userId, roleId: role.id, organizationId, })), ); }); return this.getEmployeeUserRoles(organizationId, userId); } private async assertUserBelongsToOrganization( organizationId: string, userId: string, ) { const exists = await this.userRepository .createQueryBuilder("user") .innerJoin( "user.employee", "employee", "employee.organizationId = :organizationId AND employee.isCurrent = true", { organizationId }, ) .where("user.id = :userId", { userId }) .getExists(); if (!exists) { throw new NotFoundException("user_not_found_in_organization"); } } private mergeEmployeesByUser(employees: Employee[]) { const employeesByUserId = new Map(); for (const employee of employees) { const userId = employee.userId; const employeeId = employee.id; if (!userId) { if (employeeId) { employeesByUserId.set(employeeId, employee); } continue; } const existing = employeesByUserId.get(userId); if (!existing) { employeesByUserId.set(userId, employee); continue; } const existingPositions = existing.employeePositions ?? []; const nextPositions = employee.employeePositions ?? []; const mergedEmployeePositions = Array.from( new Map( [...existingPositions, ...nextPositions].map((employeePosition) => [ employeePosition.id, employeePosition, ]), ).values(), ); employeesByUserId.set(userId, { ...existing, ...employee, id: existing.id, user: existing.user ?? employee.user, userId, name: existing.name ?? employee.name, status: existing.status ?? employee.status, employeePositions: mergedEmployeePositions, }); } return [...employeesByUserId.values()]; } private async ensureOrganizationAdminAccess( manager: EntityManager, organizationId: string, userId: string, ) { const roles = await manager.getRepository(Role).find({ where: [ { key: ORGANIZATION_ADMIN_ROLE_KEY }, { key: EDR_ORG_MANAGER_ROLE_KEY }, ], select: { id: true, key: true }, }); const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => { const role = roles.find((item) => item.key === key); if (!role?.id) { throw new NotFoundException(`required_role_not_seeded:${key}`); } return { id: role.id, key: role.key, }; }); const existingRoleIds = new Set( ( await manager.getRepository(UserRole).find({ where: { userId, organizationId, }, select: { roleId: true }, }) ).map((userRole) => userRole.roleId), ); const rolesToInsert = requiredRoles .filter((role) => !existingRoleIds.has(role.id)) .map((role) => ({ userId, roleId: role.id, organizationId, })); if (!rolesToInsert.length) { return; } await manager.getRepository(UserRole).insert(rolesToInsert); } }