chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
)
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(80)
|
||||
@Matches(/^[a-z][a-z0-9_-]+$/)
|
||||
code!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(1000)
|
||||
description!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsUUID('4', { each: true })
|
||||
permissionIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ArrayUnique, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReplaceRolePermissionsDto {
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsUUID('4', { each: true })
|
||||
permissionIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateRoleDto {
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../../common/http/request-context';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@Controller('roles')
|
||||
export class RolesController {
|
||||
constructor(private readonly roles: RolesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('roles.read')
|
||||
list() {
|
||||
return this.roles.list();
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@RequirePermissions('roles.read')
|
||||
listPermissions() {
|
||||
return this.roles.listPermissions();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('roles.manage')
|
||||
create(
|
||||
@Body() dto: CreateRoleDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.roles.create(dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('roles.read')
|
||||
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.roles.getById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('roles.manage')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateRoleDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.roles.update(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Put(':id/permissions')
|
||||
@RequirePermissions('roles.manage')
|
||||
replacePermissions(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ReplaceRolePermissionsDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.roles.replacePermissions(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user