import { BadRequestException, ConflictException, Injectable, NotFoundException, } from "@nestjs/common"; import * as argon2 from "argon2"; import { Prisma } from "@jorgecuadros/database"; import type { User } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; import { CreateUserDto } from "./create-user.dto"; import { UpdateUserDto } from "./update-user.dto"; /** Shape returned to the UI — never carries passwordHash. */ const safeSelect = { id: true, name: true, email: true, role: true, active: true, uiScale: true, createdAt: true, updatedAt: true, } satisfies Prisma.UserSelect; export type SafeUserRow = Prisma.UserGetPayload<{ select: typeof safeSelect }>; @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) {} // --- used by auth (need the hash / full row) ----------------------------- findByEmail(email: string): Promise { return this.prisma.user.findUnique({ where: { email } }); } findById(id: string): Promise { return this.prisma.user.findUnique({ where: { id } }); } // --- admin CRUD (safe rows only) ----------------------------------------- list(): Promise { return this.prisma.user.findMany({ orderBy: [{ active: "desc" }, { name: "asc" }], select: safeSelect, }); } async create(dto: CreateUserDto): Promise { const passwordHash = await argon2.hash(dto.password); try { return await this.prisma.user.create({ data: { name: dto.name, email: dto.email, passwordHash, role: dto.role, active: dto.active ?? true, }, select: safeSelect, }); } catch (e) { throw this.mapError(e); } } /** * `actingUserId` is the admin making the change — used to stop an admin from * locking themselves out (deactivating or demoting their own account). */ async update( id: string, dto: UpdateUserDto, actingUserId: string, ): Promise { await this.ensureExists(id); if (id === actingUserId) { if (dto.active === false) { throw new BadRequestException("No puede desactivar su propia cuenta"); } if (dto.role && dto.role !== "ADMIN") { throw new BadRequestException("No puede quitarse su propio rol de administrador"); } } try { return await this.prisma.user.update({ where: { id }, data: { name: dto.name, email: dto.email, role: dto.role, active: dto.active, }, select: safeSelect, }); } catch (e) { throw this.mapError(e); } } /** * Self-service preference write — no ability check, because the only account * it can touch is the caller's own (the controller passes the session id). */ updatePreferences(id: string, uiScale: number): Promise { return this.prisma.user.update({ where: { id }, data: { uiScale }, select: safeSelect, }); } async resetPassword(id: string, password: string): Promise { await this.ensureExists(id); const passwordHash = await argon2.hash(password); return this.prisma.user.update({ where: { id }, data: { passwordHash }, select: safeSelect, }); } /** * Hard-delete a user. The schema's ActivityLog.userId FK would otherwise * block the row (default `Restrict`), so null it out in the same * transaction. Rows + the actor id captured in the `message` JSON stay * intact for the audit trail. */ async remove(id: string, actingUserId: string): Promise { if (id === actingUserId) { throw new BadRequestException("No puede eliminar su propia cuenta"); } await this.ensureExists(id); try { await this.prisma.$transaction([ this.prisma.activityLog.updateMany({ where: { userId: id }, data: { userId: null }, }), this.prisma.user.delete({ where: { id } }), ]); } catch (e) { throw this.mapError(e); } } private async ensureExists(id: string): Promise { const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } }); if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`); } private mapError(e: unknown): Error { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { return new ConflictException("Ya existe un usuario con ese correo"); } return e as Error; } }