import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager, In } from 'typeorm'; import { AuditService } from '../../audit/audit.service'; import type { AuthPrincipal, RequestWithContext, } from '../../common/http/request-context'; import { AuditAction, Permission, Role, RolePermission, } from '../../database/entities'; import { administrationAuditContext, assertAdministrativeRecoveryRemains, isUniqueViolation, } from '../common/administration-audit'; import type { CreateRoleDto } from './dto/create-role.dto'; import type { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto'; import type { UpdateRoleDto } from './dto/update-role.dto'; export interface PermissionView { id: string; code: string; description: string; } export interface AdministrativeRoleView { id: string; code: string; name: string; description: string; isSystem: boolean; createdAt: Date; updatedAt: Date; userCount: number; permissions: PermissionView[]; } function roleNotFound(): NotFoundException { return new NotFoundException({ code: 'ROLE_NOT_FOUND', message: 'Rol no encontrado', }); } function permissionSelectionInvalid(): BadRequestException { return new BadRequestException({ code: 'PERMISSION_NOT_FOUND', message: 'Uno o más permisos no existen', }); } @Injectable() export class RolesService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, ) {} async list(): Promise<{ data: AdministrativeRoleView[] }> { const rows = (await this.dataSource.query(this.roleViewQuery(''), [])) as AdministrativeRoleView[]; return { data: rows }; } async listPermissions(): Promise<{ data: PermissionView[] }> { const rows = (await this.dataSource.query( ` SELECT id, code, description FROM permissions ORDER BY code ASC `, )) as PermissionView[]; return { data: rows }; } async getById(id: string): Promise { return this.dataSource.transaction((manager) => this.loadRoleView(manager, id), ); } async create( dto: CreateRoleDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { try { return await this.dataSource.transaction(async (manager) => { const permissions = await this.resolvePermissions( manager, dto.permissionIds, ); const role = manager.getRepository(Role).create({ code: dto.code.trim().toLowerCase(), name: dto.name.trim(), description: dto.description.trim(), isSystem: false, }); await manager.getRepository(Role).save(role); await this.insertPermissions(manager, role.id, permissions); const created = await this.loadRoleView(manager, role.id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ROLE_CREATED, entityType: 'role', entityId: role.id, afterData: { ...created }, }, manager, ); return created; }); } catch (error) { if (isUniqueViolation(error)) throw this.roleConflict(); throw error; } } async update( id: string, dto: UpdateRoleDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if (dto.name === undefined && dto.description === undefined) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios', }); } return this.dataSource.transaction(async (manager) => { const role = await this.lockRole(manager, id); const before = await this.loadRoleView(manager, id); if (dto.name !== undefined) role.name = dto.name.trim(); if (dto.description !== undefined) { role.description = dto.description.trim(); } await manager.getRepository(Role).save(role); const updated = await this.loadRoleView(manager, id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ROLE_UPDATED, entityType: 'role', entityId: id, beforeData: { name: before.name, description: before.description, }, afterData: { name: updated.name, description: updated.description, }, }, manager, ); return updated; }); } async replacePermissions( id: string, dto: ReplaceRolePermissionsDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { await this.lockRole(manager, id); const permissions = await this.resolvePermissions( manager, dto.permissionIds, ); const before = await this.loadRoleView(manager, id); const beforeIds = before.permissions .map((permission) => permission.id) .sort(); const afterIds = permissions.map((permission) => permission.id).sort(); if (beforeIds.join(',') === afterIds.join(',')) return before; await manager.getRepository(RolePermission).delete({ roleId: id }); await this.insertPermissions(manager, id, permissions); await assertAdministrativeRecoveryRemains(manager); const updated = await this.loadRoleView(manager, id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ROLE_PERMISSIONS_CHANGED, entityType: 'role', entityId: id, beforeData: { permissions: before.permissions }, afterData: { permissions: updated.permissions }, }, manager, ); return updated; }); } private async lockRole(manager: EntityManager, id: string): Promise { const role = await manager .getRepository(Role) .createQueryBuilder('role') .where('role.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!role) throw roleNotFound(); return role; } private async resolvePermissions( manager: EntityManager, permissionIds: string[], ): Promise { const uniqueIds = [...new Set(permissionIds)]; if (uniqueIds.length === 0) return []; const permissions = await manager.getRepository(Permission).find({ where: { id: In(uniqueIds) }, order: { code: 'ASC' }, }); if (permissions.length !== uniqueIds.length) { throw permissionSelectionInvalid(); } return permissions; } private async insertPermissions( manager: EntityManager, roleId: string, permissions: Permission[], ): Promise { if (permissions.length === 0) return; const assignments = permissions.map((permission) => manager.getRepository(RolePermission).create({ roleId, permissionId: permission.id, }), ); await manager.getRepository(RolePermission).save(assignments); } private async loadRoleView( manager: EntityManager, id: string, ): Promise { const rows = (await manager.query( this.roleViewQuery('WHERE role.id = $1'), [id], )) as AdministrativeRoleView[]; if (!rows[0]) throw roleNotFound(); return rows[0]; } private roleViewQuery(where: string): string { return ` SELECT role.id, role.code, role.name, role.description, role.is_system AS "isSystem", role.created_at AS "createdAt", role.updated_at AS "updatedAt", ( SELECT COUNT(*)::integer FROM user_roles user_role WHERE user_role.role_id = role.id ) AS "userCount", COALESCE( ( SELECT JSONB_AGG( JSONB_BUILD_OBJECT( 'id', permission.id, 'code', permission.code, 'description', permission.description ) ORDER BY permission.code ) FROM role_permissions role_permission INNER JOIN permissions permission ON permission.id = role_permission.permission_id WHERE role_permission.role_id = role.id ), '[]'::jsonb ) AS permissions FROM roles role ${where} ORDER BY role.is_system DESC, role.code ASC `; } private roleConflict(): ConflictException { return new ConflictException({ code: 'ROLE_ALREADY_EXISTS', message: 'Ya existe un rol con ese código', }); } }