310 lines
8.7 KiB
TypeScript
310 lines
8.7 KiB
TypeScript
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<AdministrativeRoleView> {
|
|
return this.dataSource.transaction((manager) =>
|
|
this.loadRoleView(manager, id),
|
|
);
|
|
}
|
|
|
|
async create(
|
|
dto: CreateRoleDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<AdministrativeRoleView> {
|
|
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<AdministrativeRoleView> {
|
|
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<AdministrativeRoleView> {
|
|
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<Role> {
|
|
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<Permission[]> {
|
|
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<void> {
|
|
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<AdministrativeRoleView> {
|
|
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',
|
|
});
|
|
}
|
|
}
|