544 lines
17 KiB
TypeScript
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',
|
|
});
|
|
}
|
|
}
|