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,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',
});
}
}