import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; import { In, MoreThan, Repository } from "typeorm"; import { CreateUserDto } from "./dto/create_user.dto"; import { CreateAdminDto } from "./dto/create_admin.dto"; import { UpdateUserDto } from "./dto/update_user.dto"; import * as bcrypt from 'bcrypt'; import { StatutValidationType, Validation } from "src/entities/validations.entity"; import { Parents } from "src/entities/parents.entity"; import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity"; import { MailService } from "src/modules/mail/mail.service"; import * as crypto from 'crypto'; @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); constructor( @InjectRepository(Users) private readonly usersRepository: Repository, @InjectRepository(Validation) private readonly validationRepository: Repository, @InjectRepository(Parents) private readonly parentsRepository: Repository, @InjectRepository(AssistanteMaternelle) private readonly assistantesRepository: Repository, private readonly mailService: MailService, ) { } async createUser(dto: CreateUserDto, currentUser?: Users): Promise { if (!dto.cguAccepted) { throw new BadRequestException( 'Vous devez accepter les CGU et la Politique de confidentialité pour créer un compte.', ); } const exist = await this.usersRepository.findOneBy({ email: dto.email }); if (exist) throw new BadRequestException('Email déjà utilisé'); const isSuperAdmin = currentUser?.role === RoleType.SUPER_ADMIN; const isAdmin = currentUser?.role === RoleType.ADMINISTRATEUR; let role: RoleType; if (dto.role === RoleType.GESTIONNAIRE) { if (!isAdmin && !isSuperAdmin) { throw new ForbiddenException('Seuls les administrateurs peuvent créer un gestionnaire'); } role = RoleType.GESTIONNAIRE; } else if (dto.role === RoleType.ADMINISTRATEUR) { if (!isAdmin && !isSuperAdmin) { throw new ForbiddenException('Seuls les administrateurs peuvent créer un administrateur'); } role = RoleType.ADMINISTRATEUR; } else if (dto.role === RoleType.ASSISTANTE_MATERNELLE) { role = RoleType.ASSISTANTE_MATERNELLE; if (!dto.photo_url) { throw new BadRequestException( 'La photo de profil est obligatoire pour les assistantes maternelles.', ); } } else { role = RoleType.PARENT; } const statut = isSuperAdmin ? dto.statut ?? StatutUtilisateurType.EN_ATTENTE : StatutUtilisateurType.EN_ATTENTE; if (!dto.nom?.trim()) throw new BadRequestException('Nom est obligatoire.'); if (!dto.prenom?.trim()) throw new BadRequestException('Prénom est obligatoire.'); if (!dto.adresse?.trim()) throw new BadRequestException('Adresse est obligatoire.'); if (!dto.telephone?.trim()) throw new BadRequestException('Téléphone est obligatoire.'); let consentDate: Date | undefined; if (dto.consentement_photo && dto.date_consentement_photo) { const parsed = new Date(dto.date_consentement_photo); if (!isNaN(parsed.getTime())) { consentDate = parsed; } } const salt = await bcrypt.genSalt(); const hashedPassword = await bcrypt.hash(dto.password, salt); const entity = this.usersRepository.create({ email: dto.email, password: hashedPassword, prenom: dto.prenom, nom: dto.nom, role, statut, genre: dto.genre, telephone: dto.telephone, ville: dto.ville, code_postal: dto.code_postal, adresse: dto.adresse, photo_url: dto.photo_url, consentement_photo: dto.consentement_photo ?? false, date_consentement_photo: consentDate, changement_mdp_obligatoire: role === RoleType.ADMINISTRATEUR || role === RoleType.GESTIONNAIRE ? true : dto.changement_mdp_obligatoire ?? false, }); const saved = await this.usersRepository.save(entity); return this.findOne(saved.id); } async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur'); } const exist = await this.usersRepository.findOneBy({ email: dto.email }); if (exist) throw new BadRequestException('Email déjà utilisé'); const salt = await bcrypt.genSalt(); const hashedPassword = await bcrypt.hash(dto.password, salt); const entity = this.usersRepository.create({ email: dto.email, password: hashedPassword, prenom: dto.prenom, nom: dto.nom, role: RoleType.ADMINISTRATEUR, statut: StatutUtilisateurType.ACTIF, telephone: dto.telephone, changement_mdp_obligatoire: true, }); return this.usersRepository.save(entity); } async findPendingUsers(role?: RoleType): Promise { const where: any = { statut: StatutUtilisateurType.EN_ATTENTE }; if (role) { where.role = role; } return this.usersRepository.find({ where }); } /** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */ async findRefusedUsers(role?: RoleType): Promise { const where: any = { statut: StatutUtilisateurType.REFUSE }; if (role) { where.role = role; } return this.usersRepository.find({ where }); } async findAll(): Promise { return this.usersRepository.find(); } async findOneBy(where: Partial): Promise { return this.usersRepository.findOne({ where }); } async findOne(id: string): Promise { const user = await this.usersRepository.findOne({ where: { id } }); if (!user) { throw new NotFoundException('Utilisateur introuvable'); } return user; } async findByEmailOrNull(email: string): Promise { return this.usersRepository.findOne({ where: { email } }); } async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise { const user = await this.findOne(id); // Le super administrateur conserve une identité figée. if ( user.role === RoleType.SUPER_ADMIN && (dto.nom !== undefined || dto.prenom !== undefined) ) { throw new ForbiddenException( 'Le nom et le prénom du super administrateur ne peuvent pas être modifiés', ); } // Interdire changement de rôle si pas super admin if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); } // Un admin ne peut pas modifier un super admin if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) { throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur'); } // Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire if ( (user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) && dto.changement_mdp_obligatoire === false ) { throw new ForbiddenException( 'Impossible de désactiver l’obligation de changement de mot de passe pour ce rôle', ); } // Gestion du mot de passe if (dto.password) { const salt = await bcrypt.genSalt(); user.password = await bcrypt.hash(dto.password, salt); delete (dto as any).password; // Une fois le mot de passe changé, on peut lever l’obligation user.changement_mdp_obligatoire = false; } // Conversion de la date de consentement if (dto.date_consentement_photo !== undefined) { user.date_consentement_photo = dto.date_consentement_photo ? new Date(dto.date_consentement_photo) : undefined; delete (dto as any).date_consentement_photo; } Object.assign(user, dto); return this.usersRepository.save(user); } // Valider un compte utilisateur (en_attente ou refuse -> actif) async validateUser(user_id: string, currentUser: Users, comment?: string): Promise { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); } const user = await this.usersRepository.findOne({ where: { id: user_id } }); if (!user) throw new NotFoundException('Utilisateur introuvable'); if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) { throw new BadRequestException('Seuls les comptes en attente ou refusés (à corriger) peuvent être validés.'); } user.statut = StatutUtilisateurType.ACTIF; const savedUser = await this.usersRepository.save(user); if (user.role === RoleType.PARENT) { const existParent = await this.parentsRepository.findOneBy({ user_id: user.id }); if (!existParent) { const parentEntity = this.parentsRepository.create({ user_id: user.id, user }); await this.parentsRepository.save(parentEntity); } } else if (user.role === RoleType.ASSISTANTE_MATERNELLE) { const existAssistante = await this.assistantesRepository.findOneBy({ user_id: user.id }); if (!existAssistante) { const assistanteEntity = this.assistantesRepository.create({ user_id: user.id, user }); await this.assistantesRepository.save(assistanteEntity); } } const validation = this.validationRepository.create({ user: savedUser, type: 'validation_compte', status: StatutValidationType.VALIDE, validated_by: currentUser, comment, }); await this.validationRepository.save(validation); return savedUser; } // Mettre un compte en statut suspendu async suspendUser(user_id: string, currentUser: Users, comment?: string): Promise { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); } const user = await this.usersRepository.findOne({ where: { id: user_id } }); if (!user) throw new NotFoundException('Utilisateur introuvable'); user.statut = StatutUtilisateurType.SUSPENDU; const savedUser = await this.usersRepository.save(user); const suspend = this.validationRepository.create({ user: savedUser, type: 'suspension_compte', status: StatutValidationType.VALIDE, validated_by: currentUser, comment, }) await this.validationRepository.save(suspend); return savedUser; } /** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */ async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); } const user = await this.usersRepository.findOne({ where: { id: user_id } }); if (!user) throw new NotFoundException('Utilisateur introuvable'); if (user.statut !== StatutUtilisateurType.EN_ATTENTE) { throw new BadRequestException('Seul un compte en attente peut être refusé.'); } const tokenReprise = crypto.randomUUID(); const expireLe = new Date(); expireLe.setDate(expireLe.getDate() + 7); user.statut = StatutUtilisateurType.REFUSE; user.token_reprise = tokenReprise; user.token_reprise_expire_le = expireLe; const savedUser = await this.usersRepository.save(user); const validation = this.validationRepository.create({ user: savedUser, type: 'refus_compte', status: StatutValidationType.REFUSE, validated_by: currentUser, comment, }); await this.validationRepository.save(validation); try { await this.mailService.sendRefusEmail( savedUser.email, savedUser.prenom ?? '', savedUser.nom ?? '', comment, tokenReprise, ); } catch (err) { this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err); } return savedUser; } /** * Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM). * Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes). * Garde-fou : au plus 2 parents par numéro de dossier (couple / co-parents). */ async affecterNumeroDossier(userId: string, numeroDossier: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId } }); if (!user) throw new NotFoundException('Utilisateur introuvable'); if (user.role !== RoleType.PARENT && user.role !== RoleType.ASSISTANTE_MATERNELLE) { throw new BadRequestException( 'Le numéro de dossier ne peut être affecté qu\'à un parent ou une assistante maternelle', ); } if (user.role === RoleType.PARENT) { const uneAMALe = await this.assistantesRepository.count({ where: { numero_dossier: numeroDossier }, }); if (uneAMALe > 0) { throw new BadRequestException( 'Ce numéro de dossier est celui d\'une assistante maternelle. Un numéro AM ne peut pas être affecté à un parent.', ); } const parentsAvecCeNumero = await this.parentsRepository.count({ where: { numero_dossier: numeroDossier }, }); const userADejaCeNumero = user.numero_dossier === numeroDossier; if (!userADejaCeNumero && parentsAvecCeNumero >= 2) { throw new BadRequestException( 'Un numéro de dossier ne peut être associé qu\'à 2 parents au maximum (couple / co-parents). Ce dossier a déjà 2 parents.', ); } } if (user.role === RoleType.ASSISTANTE_MATERNELLE) { const unParentLA = await this.parentsRepository.count({ where: { numero_dossier: numeroDossier }, }); if (unParentLA > 0) { throw new BadRequestException( 'Ce numéro de dossier est celui d\'une famille (parent). Un numéro famille ne peut pas être affecté à une assistante maternelle.', ); } } user.numero_dossier = numeroDossier; const savedUser = await this.usersRepository.save(user); if (user.role === RoleType.PARENT) { await this.parentsRepository.update({ user_id: userId }, { numero_dossier: numeroDossier }); } else { await this.assistantesRepository.update({ user_id: userId }, { numero_dossier: numeroDossier }); } return savedUser; } /** Trouve un user par token reprise valide (non expiré). Ticket #111 */ async findByTokenReprise(token: string): Promise { return this.usersRepository.findOne({ where: { token_reprise: token, statut: StatutUtilisateurType.REFUSE, token_reprise_expire_le: MoreThan(new Date()), }, }); } /** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */ async resoumettreReprise( token: string, dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, ): Promise { const user = await this.findByTokenReprise(token); if (!user) { throw new NotFoundException('Token reprise invalide ou expiré.'); } if (dto.prenom !== undefined) user.prenom = dto.prenom; if (dto.nom !== undefined) user.nom = dto.nom; if (dto.telephone !== undefined) user.telephone = dto.telephone; if (dto.adresse !== undefined) user.adresse = dto.adresse; if (dto.ville !== undefined) user.ville = dto.ville; if (dto.code_postal !== undefined) user.code_postal = dto.code_postal; if (dto.photo_url !== undefined) user.photo_url = dto.photo_url; user.statut = StatutUtilisateurType.EN_ATTENTE; user.token_reprise = undefined; user.token_reprise_expire_le = undefined; return this.usersRepository.save(user); } /** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */ async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise { const user = await this.usersRepository.findOne({ where: { email: email.trim().toLowerCase(), numero_dossier: numero_dossier.trim(), statut: StatutUtilisateurType.REFUSE, token_reprise_expire_le: MoreThan(new Date()), }, }); return user ?? null; } async remove(id: string, currentUser: Users): Promise { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); } const user = await this.findOne(id); if (user.role === RoleType.SUPER_ADMIN) { throw new ForbiddenException( 'Le super administrateur ne peut pas être supprimé', ); } const result = await this.usersRepository.delete(id); if (result.affected === 0) { throw new NotFoundException('Utilisateur introuvable'); } } }