chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { PhaseADataModule } from '../core-data/phase-a-data.module';
import { RolesController } from './roles/roles.controller';
import { RolesService } from './roles/roles.service';
import { UsersController } from './users/users.controller';
import { UsersService } from './users/users.service';
@Module({
imports: [PhaseADataModule, AuditModule, AuthModule],
controllers: [UsersController, RolesController],
providers: [UsersService, RolesService],
})
export class AdministrationModule {}
@@ -0,0 +1,72 @@
import { isIP } from 'node:net';
import { ConflictException } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import { AuditSource } from '../../database/entities';
const REQUIRED_RECOVERY_PERMISSIONS = [
'roles.manage',
'users.assign_roles',
] as const;
export function administrationAuditContext(
principal: AuthPrincipal,
request: RequestWithContext,
) {
const candidateIp = request.ip || request.socket.remoteAddress || '';
const rawUserAgent = request.header('user-agent')?.trim();
return {
actorUserId: principal.userId,
actorUsername: principal.username,
requestId: request.requestId,
source: AuditSource.WEB,
ip: isIP(candidateIp) ? candidateIp : null,
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
};
}
export async function assertAdministrativeRecoveryRemains(
manager: EntityManager,
): Promise<void> {
const [row] = (await manager.query(
`
SELECT COUNT(*)::integer AS count
FROM (
SELECT user_account.id
FROM users user_account
INNER JOIN user_roles user_role
ON user_role.user_id = user_account.id
INNER JOIN role_permissions role_permission
ON role_permission.role_id = user_role.role_id
INNER JOIN permissions permission
ON permission.id = role_permission.permission_id
WHERE user_account.status = 'ACTIVE'
AND permission.code = ANY($1::varchar[])
GROUP BY user_account.id
HAVING COUNT(DISTINCT permission.code) = $2
) administrators
`,
[REQUIRED_RECOVERY_PERMISSIONS, REQUIRED_RECOVERY_PERMISSIONS.length],
)) as Array<{ count: number }>;
if (!row || Number(row.count) < 1) {
throw new ConflictException({
code: 'LAST_ADMINISTRATOR_PROTECTED',
message:
'Debe permanecer al menos un usuario activo capaz de administrar roles y asignaciones',
});
}
}
export function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === '23505'
);
}
@@ -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',
});
}
}
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { UserStatus } from '../../../database/entities';
export class ChangeUserStatusDto {
@IsEnum(UserStatus)
status!: UserStatus;
}
@@ -0,0 +1,58 @@
import { Transform } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEmail,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateUserDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsString()
@MinLength(3)
@MaxLength(80)
@Matches(/^[a-zA-Z0-9._-]+$/)
username!: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
@IsString()
@MinLength(12)
@MaxLength(128)
password!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(120)
firstName!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(120)
lastName!: string;
@IsOptional()
@IsBoolean()
mustChangePassword = true;
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
roleIds!: string[];
}
@@ -0,0 +1,35 @@
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { UserStatus } from '../../../database/entities';
export class ListUsersQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsEnum(UserStatus)
status?: UserStatus;
}
@@ -0,0 +1,8 @@
import { ArrayUnique, IsArray, IsUUID } from 'class-validator';
export class ReplaceUserRolesDto {
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
roleIds!: string[];
}
@@ -0,0 +1,12 @@
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class ResetUserPasswordDto {
@IsString()
@MinLength(12)
@MaxLength(128)
password!: string;
@IsOptional()
@IsBoolean()
mustChangePassword = true;
}
@@ -0,0 +1,43 @@
import { Transform } from 'class-transformer';
import {
IsEmail,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class UpdateUserDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(80)
@Matches(/^[a-zA-Z0-9._-]+$/)
username?: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
firstName?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
lastName?: string;
}
@@ -0,0 +1,96 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
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 { ChangeUserStatusDto } from './dto/change-user-status.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
@RequirePermissions('users.read')
list(@Query() query: ListUsersQueryDto) {
return this.users.list(query);
}
@Post()
@RequirePermissions('users.create')
create(
@Body() dto: CreateUserDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.create(dto, principal, request);
}
@Get(':id')
@RequirePermissions('users.read')
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.users.getById(id);
}
@Patch(':id')
@RequirePermissions('users.update')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateUserDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.update(id, dto, principal, request);
}
@Patch(':id/status')
@RequirePermissions('users.change_status')
changeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeUserStatusDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.changeStatus(id, dto, principal, request);
}
@Post(':id/reset-password')
@RequirePermissions('users.update')
resetPassword(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ResetUserPasswordDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.resetPassword(id, dto, principal, request);
}
@Put(':id/roles')
@RequirePermissions('users.assign_roles')
replaceRoles(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ReplaceUserRolesDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.replaceRoles(id, dto, principal, request);
}
}
@@ -0,0 +1,497 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, In } from 'typeorm';
import { AuditService } from '../../audit/audit.service';
import { PasswordService } from '../../auth/services/password.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import { AuthSessionsRepository } from '../../core-data/repositories/auth-sessions.repository';
import {
AuditAction,
Role,
User,
UserRole,
UserStatus,
} from '../../database/entities';
import {
administrationAuditContext,
assertAdministrativeRecoveryRemains,
isUniqueViolation,
} from '../common/administration-audit';
import type { ChangeUserStatusDto } from './dto/change-user-status.dto';
import type { CreateUserDto } from './dto/create-user.dto';
import type { ListUsersQueryDto } from './dto/list-users-query.dto';
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import type { UpdateUserDto } from './dto/update-user.dto';
export interface UserRoleView {
id: string;
code: string;
name: string;
}
export interface AdministrativeUserView {
id: string;
username: string;
email: string | null;
firstName: string;
lastName: string;
status: UserStatus;
mustChangePassword: boolean;
failedLoginAttempts: number;
lockedUntil: Date | null;
lastLoginAt: Date | null;
passwordChangedAt: Date | null;
createdAt: Date;
updatedAt: Date;
roles: UserRoleView[];
}
interface UserViewRow extends AdministrativeUserView {
total?: string | number;
}
function userNotFound(): NotFoundException {
return new NotFoundException({
code: 'USER_NOT_FOUND',
message: 'Usuario no encontrado',
});
}
function roleSelectionInvalid(): BadRequestException {
return new BadRequestException({
code: 'ROLE_NOT_FOUND',
message: 'Uno o más roles no existen',
});
}
@Injectable()
export class UsersService {
constructor(
private readonly dataSource: DataSource,
private readonly passwords: PasswordService,
private readonly sessions: AuthSessionsRepository,
private readonly audit: AuditService,
) {}
async list(query: ListUsersQueryDto) {
const page = query.page;
const pageSize = query.pageSize;
const filters: string[] = [];
const parameters: unknown[] = [];
const search = query.search?.trim();
if (search) {
parameters.push(`%${search}%`);
filters.push(`
(
user_account.username ILIKE $${parameters.length}
OR user_account.email ILIKE $${parameters.length}
OR user_account.first_name ILIKE $${parameters.length}
OR user_account.last_name ILIKE $${parameters.length}
)
`);
}
if (query.status) {
parameters.push(query.status);
filters.push(`user_account.status = $${parameters.length}`);
}
parameters.push(pageSize, (page - 1) * pageSize);
const limitParameter = parameters.length - 1;
const offsetParameter = parameters.length;
const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
const rows = (await this.dataSource.query(
`
SELECT
user_account.id,
user_account.username,
user_account.email,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.status,
user_account.must_change_password AS "mustChangePassword",
user_account.failed_login_attempts AS "failedLoginAttempts",
user_account.locked_until AS "lockedUntil",
user_account.last_login_at AS "lastLoginAt",
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', role.id,
'code', role.code,
'name', role.name
) ORDER BY role.code
) FILTER (WHERE role.id IS NOT NULL),
'[]'::jsonb
) AS roles,
COUNT(*) OVER() AS total
FROM users user_account
LEFT JOIN user_roles user_role
ON user_role.user_id = user_account.id
LEFT JOIN roles role ON role.id = user_role.role_id
${where}
GROUP BY user_account.id
ORDER BY user_account.created_at DESC, user_account.username ASC
LIMIT $${limitParameter} OFFSET $${offsetParameter}
`,
parameters,
)) as UserViewRow[];
const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0;
return {
data: rows.map(({ total: _total, ...row }) => row),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async getById(id: string): Promise<AdministrativeUserView> {
return this.dataSource.transaction(async (manager) =>
this.loadUserView(manager, id),
);
}
async create(
dto: CreateUserDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
const passwordHash = await this.passwords.hash(dto.password);
try {
return await this.dataSource.transaction(async (manager) => {
const roles = await this.resolveRoles(manager, dto.roleIds);
const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null,
passwordHash,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
status: UserStatus.ACTIVE,
mustChangePassword: dto.mustChangePassword,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
passwordChangedAt: null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(User).save(user);
await this.insertUserRoles(
manager,
user.id,
roles,
principal.userId,
);
const created = await this.loadUserView(manager, user.id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_CREATED,
entityType: 'user',
entityId: user.id,
afterData: { ...created },
},
manager,
);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.userConflict();
throw error;
}
}
async update(
id: string,
dto: UpdateUserDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
if (
dto.username === undefined &&
dto.email === undefined &&
dto.firstName === undefined &&
dto.lastName === undefined
) {
throw new BadRequestException({
code: 'NO_CHANGES',
message: 'No se recibieron cambios',
});
}
try {
return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase();
}
if (dto.email !== undefined) {
user.email = dto.email?.trim().toLowerCase() || null;
}
if (dto.firstName !== undefined) user.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId;
await manager.getRepository(User).save(user);
const updated = await this.loadUserView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user',
entityId: id,
beforeData: { ...before },
afterData: { ...updated },
},
manager,
);
return updated;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.userConflict();
throw error;
}
}
async changeStatus(
id: string,
dto: ChangeUserStatusDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
if (id === principal.userId && dto.status === UserStatus.INACTIVE) {
throw new ConflictException({
code: 'SELF_DEACTIVATION_FORBIDDEN',
message: 'No puede desactivar su propio usuario',
});
}
return this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (user.status === dto.status) return before;
user.status = dto.status;
user.updatedBy = principal.userId;
if (dto.status === UserStatus.ACTIVE) {
user.failedLoginAttempts = 0;
user.lockedUntil = null;
}
await manager.getRepository(User).save(user);
if (dto.status === UserStatus.INACTIVE) {
await this.sessions.revokeUserSessions(id, undefined, manager);
}
await assertAdministrativeRecoveryRemains(manager);
const updated = await this.loadUserView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_STATUS_CHANGED,
entityType: 'user',
entityId: id,
beforeData: { status: before.status },
afterData: { status: updated.status },
metadata: {
sessionsRevoked: dto.status === UserStatus.INACTIVE,
},
},
manager,
);
return updated;
});
}
async resetPassword(
id: string,
dto: ResetUserPasswordDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
const passwordHash = await this.passwords.hash(dto.password);
return this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
const now = new Date();
user.passwordHash = passwordHash;
user.passwordChangedAt = now;
user.mustChangePassword = dto.mustChangePassword;
user.failedLoginAttempts = 0;
user.lockedUntil = null;
user.updatedBy = principal.userId;
await manager.getRepository(User).save(user);
await this.sessions.revokeUserSessions(id, undefined, manager);
const updated = await this.loadUserView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_PASSWORD_RESET,
entityType: 'user',
entityId: id,
beforeData: {
mustChangePassword: before.mustChangePassword,
failedLoginAttempts: before.failedLoginAttempts,
lockedUntil: before.lockedUntil,
},
afterData: {
mustChangePassword: updated.mustChangePassword,
failedLoginAttempts: updated.failedLoginAttempts,
lockedUntil: updated.lockedUntil,
passwordChangedAt: updated.passwordChangedAt,
},
metadata: { sessionsRevoked: true, passwordValueRecorded: false },
},
manager,
);
return updated;
});
}
async replaceRoles(
id: string,
dto: ReplaceUserRolesDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
return this.dataSource.transaction(async (manager) => {
await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id);
const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before;
await manager.getRepository(UserRole).delete({ userId: id });
await this.insertUserRoles(manager, id, roles, principal.userId);
await assertAdministrativeRecoveryRemains(manager);
const updated = await this.loadUserView(manager, id);
await this.audit.record(
{
...administrationAuditContext(principal, request),
action: AuditAction.USER_ROLES_CHANGED,
entityType: 'user',
entityId: id,
beforeData: { roles: before.roles },
afterData: { roles: updated.roles },
},
manager,
);
return updated;
});
}
private async resolveRoles(
manager: EntityManager,
roleIds: string[],
): Promise<Role[]> {
const uniqueIds = [...new Set(roleIds)];
if (uniqueIds.length === 0) return [];
const roles = await manager.getRepository(Role).find({
where: { id: In(uniqueIds) },
order: { code: 'ASC' },
});
if (roles.length !== uniqueIds.length) throw roleSelectionInvalid();
return roles;
}
private async insertUserRoles(
manager: EntityManager,
userId: string,
roles: Role[],
assignedBy: string,
): Promise<void> {
if (roles.length === 0) return;
const assignments = roles.map((role) =>
manager.getRepository(UserRole).create({
userId,
roleId: role.id,
assignedBy,
}),
);
await manager.getRepository(UserRole).save(assignments);
}
private async lockUser(manager: EntityManager, id: string): Promise<User> {
const user = await manager
.getRepository(User)
.createQueryBuilder('user')
.where('user.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!user) throw userNotFound();
return user;
}
private async loadUserView(
manager: EntityManager,
id: string,
): Promise<AdministrativeUserView> {
const [row] = (await manager.query(
`
SELECT
user_account.id,
user_account.username,
user_account.email,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.status,
user_account.must_change_password AS "mustChangePassword",
user_account.failed_login_attempts AS "failedLoginAttempts",
user_account.locked_until AS "lockedUntil",
user_account.last_login_at AS "lastLoginAt",
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', role.id,
'code', role.code,
'name', role.name
) ORDER BY role.code
) FILTER (WHERE role.id IS NOT NULL),
'[]'::jsonb
) AS roles
FROM users user_account
LEFT JOIN user_roles user_role
ON user_role.user_id = user_account.id
LEFT JOIN roles role ON role.id = user_role.role_id
WHERE user_account.id = $1
GROUP BY user_account.id
`,
[id],
)) as AdministrativeUserView[];
if (!row) throw userNotFound();
return row;
}
private userConflict(): ConflictException {
return new ConflictException({
code: 'USER_ALREADY_EXISTS',
message: 'El usuario o email ya está registrado',
});
}
}