Files
dh-inspeccion-v2/api-v3/src/administration/users/users.service.ts
T

544 lines
17 KiB
TypeScript

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;
dni: string | null;
phone: string | null;
jobTitle: string | null;
employeeNumber: 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',
});
}
function inspectorEmailRequired(): BadRequestException {
return new BadRequestException({
code: 'INSPECTOR_EMAIL_REQUIRED',
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones',
});
}
@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}
OR user_account.dni ILIKE $${parameters.length}
OR user_account.phone ILIKE $${parameters.length}
OR user_account.job_title ILIKE $${parameters.length}
OR user_account.employee_number 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.dni,
user_account.phone,
user_account.job_title AS "jobTitle",
user_account.employee_number AS "employeeNumber",
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);
this.assertInspectorHasEmail(roles, dto.email ?? null);
const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null,
dni: dto.dni ?? null,
phone: dto.phone ?? null,
jobTitle: dto.jobTitle ?? null,
employeeNumber: dto.employeeNumber ?? 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.dni === undefined &&
dto.phone === undefined &&
dto.jobTitle === undefined &&
dto.employeeNumber === 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.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) {
throw inspectorEmailRequired();
}
if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase();
}
if (dto.email !== undefined) {
user.email = dto.email?.trim().toLowerCase() || null;
}
if (dto.dni !== undefined) user.dni = dto.dni ?? null;
if (dto.phone !== undefined) user.phone = dto.phone ?? null;
if (dto.jobTitle !== undefined) user.jobTitle = dto.jobTitle ?? null;
if (dto.employeeNumber !== undefined) user.employeeNumber = dto.employeeNumber ?? 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);
this.assertInspectorHasEmail(roles, before.email);
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 assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
throw inspectorEmailRequired();
}
}
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.dni,
user_account.phone,
user_account.job_title AS "jobTitle",
user_account.employee_number AS "employeeNumber",
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, email o DNI ya está registrado',
});
}
}