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"; // Subpath imports (not the package root) so ts-jest can resolve them when this // file lands in a spec's compile graph via the notification recipients chain. import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity"; import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity"; 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, ) {} /** * IAM user ids of every current employee across all organizations — used by * support chat for staff room membership. Notifications deliberately do NOT * use this: they target a desk via `getEmployeeUserIdsByPermission`. */ async getAllCurrentEmployeeUserIds(): Promise { const employees = await this.employeeRepository.find({ where: { isCurrent: true }, }); return [ ...new Set( employees.map((e) => e.userId).filter((id): id is string => Boolean(id)), ), ]; } /** * IAM user ids of current employees (any org) holding ANY of the given * permission keys — used by the notification recipients resolver's * `permissionKeys` selector for department/role-scoped targeting. * * This MUST agree with the request-time guard (`hasFreightPermission`, * common/freight-permission.util.ts), which counts four grant carriers plus * the super_admin bypass. Counting fewer silently drops legitimate * recipients: an earlier version joined only direct position permissions, on * which `bookings:view` resolved to 2 users — against 21 through position * TYPES, which is where admin-created positions actually keep their grants. * * Raw SQL rather than QueryBuilder because `Position.positionTypePermissions` * declares its inverse side against PositionType, so a relation join emits * `ptp.position_type_id = position.id` and silently matches nothing. Same * approach as FreightMeService's position-type lookups. */ async getEmployeeUserIdsByPermission( permissionKeys: string[], ): Promise { if (!permissionKeys.length) return []; const rows: { userId: string }[] = await this.dataSource.query( `WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1)) -- 1. IAM role grants (user_roles -> role_permissions). SELECT e.user_id AS "userId" FROM iam.employees e JOIN iam.user_roles ur ON ur.user_id = e.user_id JOIN iam.role_permissions rp ON rp.role_id = ur.role_id WHERE e.is_current AND e.user_id IS NOT NULL AND rp.permission_id IN (SELECT id FROM target) UNION -- 2. Direct position grants. A delegate keeps their own position AND -- gains the one they stand in for, so both columns count. SELECT e.user_id FROM iam.employees e JOIN iam.employee_positions ep ON ep.employee_id = e.id AND ep.is_current JOIN iam.position_permissions pp ON pp.position_id IN (ep.position_id, ep.delegatee_position_id) WHERE e.is_current AND e.user_id IS NOT NULL AND pp.permission_id IN (SELECT id FROM target) UNION -- 3. Position TYPE grants — where admin-created positions keep theirs. SELECT e.user_id FROM iam.employees e JOIN iam.employee_positions ep ON ep.employee_id = e.id AND ep.is_current JOIN iam.positions p ON p.id IN (ep.position_id, ep.delegatee_position_id) JOIN iam.position_type_permissions ptp ON ptp.position_type_id = p.position_type_id WHERE e.is_current AND e.user_id IS NOT NULL AND ptp.permission_id IN (SELECT id FROM target) UNION -- 4. super_admin passes every freight permission check, so mirror that -- here or admins go blind on desks nobody else has been granted yet. SELECT e.user_id FROM iam.employees e JOIN iam.user_roles ur ON ur.user_id = e.user_id JOIN iam.roles r ON r.id = ur.role_id WHERE e.is_current AND e.user_id IS NOT NULL AND r.key = 'super_admin'`, [permissionKeys], ); return rows.map((r) => r.userId); } 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); } }