import { ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { INVITATION_TTL_DAYS, type InvitationCreate, type InvitationResponse, type RoleName, type RolesResponse, type UserAdmin, type UsersResponse, type UserUpdate, } from '@siop/shared'; import { randomBytes } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; const userInclude = { role: true, teams: { orderBy: { name: 'asc' } }, } satisfies Prisma.UserInclude; type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>; @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) {} async list(): Promise { const rows = await this.prisma.user.findMany({ include: userInclude, orderBy: { displayName: 'asc' }, }); return { users: rows.map((u) => this.toDto(u)) }; } async roles(): Promise { const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } }); return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) }; } /** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours. * L'envoi d'email viendra plus tard — le web affiche le lien à copier. */ async invite(dto: InvitationCreate): Promise { const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } }); if (!role) throw new NotFoundException('Rôle inconnu'); try { const user = await this.prisma.user.create({ data: { email: dto.email, displayName: dto.displayName, phone: dto.phone, roleId: dto.roleId, teams: dto.teamIds?.length ? { connect: dto.teamIds.map((id) => ({ id })) } : undefined, ...this.freshToken(), }, }); return { userId: user.id, activationToken: user.activationToken!, expiresAt: user.activationExpiresAt!.toISOString(), }; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('Cet email a déjà un compte'); } throw e; } } async resendInvitation(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException('Personne inconnue'); if (user.passwordHash) { throw new ConflictException('Ce compte est déjà activé'); } const updated = await this.prisma.user.update({ where: { id: userId }, data: this.freshToken(), }); return { userId: updated.id, activationToken: updated.activationToken!, expiresAt: updated.activationExpiresAt!.toISOString(), }; } async update(userId: string, dto: UserUpdate): Promise { try { const updated = await this.prisma.user.update({ where: { id: userId }, data: { displayName: dto.displayName, phone: dto.phone, roleId: dto.roleId, isActive: dto.isActive, teams: dto.teamIds ? { set: dto.teamIds.map((id) => ({ id })) } : undefined, }, include: userInclude, }); return this.toDto(updated); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { throw new NotFoundException('Personne inconnue'); } throw e; } } private freshToken() { return { activationToken: randomBytes(32).toString('base64url'), activationExpiresAt: new Date( Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000, ), }; } private toDto(row: UserRow): UserAdmin { return { id: row.id, email: row.email, displayName: row.displayName, phone: row.phone, role: { id: row.role.id, name: row.role.name as RoleName }, teams: row.teams.map((t) => ({ id: t.id, name: t.name })), status: !row.isActive ? 'disabled' : row.passwordHash ? 'active' : 'invited', isDemo: row.isDemo, }; } }