chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user