import { ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EntityManager, In, QueryFailedError, Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; import { RegisterDto } from './dto/register.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ConfigService } from '@nestjs/config'; import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { Parents } from 'src/entities/parents.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; import { StatutDossierType } from 'src/entities/dossiers.entity'; import { LoginDto } from './dto/login.dto'; import { RepriseDossierDto } from './dto/reprise-dossier.dto'; import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto'; import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { MailService } from 'src/modules/mail/mail.service'; import { ParentsService } from '../parents/parents.service'; import { DossiersService } from '../dossiers/dossiers.service'; import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto'; import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( private readonly usersService: UserService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly appConfigService: AppConfigService, private readonly mailService: MailService, private readonly numeroDossierService: NumeroDossierService, private readonly parentsService: ParentsService, private readonly dossiersService: DossiersService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @InjectRepository(Users) private readonly usersRepo: Repository, @InjectRepository(Children) private readonly childrenRepo: Repository, @InjectRepository(AssistanteMaternelle) private readonly assistantesMaternellesRepo: Repository, ) { } /** * Génère un access_token et un refresh_token */ async generateTokens(userId: string, email: string, role: RoleType) { const accessSecret = this.configService.get('jwt.accessSecret'); const accessExpiresIn = this.configService.get('jwt.accessExpiresIn'); const refreshSecret = this.configService.get('jwt.refreshSecret'); const refreshExpiresIn = this.configService.get('jwt.refreshExpiresIn'); const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync({ sub: userId, email, role }, { secret: accessSecret, expiresIn: accessExpiresIn }), this.jwtService.signAsync({ sub: userId }, { secret: refreshSecret, expiresIn: refreshExpiresIn }), ]); return { access_token: accessToken, refresh_token: refreshToken, }; } /** * Connexion utilisateur */ async login(dto: LoginDto) { const user = await this.usersService.findByEmailOrNull(dto.email); if (!user) { throw new UnauthorizedException('Identifiants invalides'); } // Vérifier que le mot de passe existe (compte activé) if (!user.password) { throw new UnauthorizedException( 'Compte non activé. Veuillez créer votre mot de passe via le lien reçu par email.', ); } // Vérifier le mot de passe const isMatch = await bcrypt.compare(dto.password, user.password); if (!isMatch) { throw new UnauthorizedException('Identifiants invalides'); } // Vérifier le statut du compte if (user.statut === StatutUtilisateurType.EN_ATTENTE) { throw new UnauthorizedException( 'Votre compte est en attente de validation par un gestionnaire.', ); } if (user.statut === StatutUtilisateurType.SUSPENDU) { throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.'); } if (user.statut === StatutUtilisateurType.REFUSE) { throw new UnauthorizedException( 'Votre compte a été refusé. Vous pouvez corriger votre dossier et le soumettre à nouveau ; un gestionnaire pourra le réexaminer.', ); } return this.generateTokens(user.id, user.email, user.role); } /** * Rafraîchir les tokens */ async refreshTokens(refreshToken: string) { try { const payload = await this.jwtService.verifyAsync(refreshToken, { secret: this.configService.get('jwt.refreshSecret'), }); const user = await this.usersService.findOne(payload.sub); if (!user) { throw new UnauthorizedException('Utilisateur introuvable'); } return this.generateTokens(user.id, user.email, user.role); } catch { throw new UnauthorizedException('Refresh token invalide'); } } async verifyCreatePasswordToken(token: string) { const cleanedToken = token?.trim(); if (!cleanedToken) { // #118: côté front, un token absent doit être traité comme lien invalide/expiré. // On renvoie donc la même réponse neutre 404 que pour les autres cas invalides. throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } const user = await this.usersRepo.findOne({ where: { token_creation_mdp: cleanedToken }, select: ['id', 'token_creation_mdp_expire_le', 'password'], }); if (!user) { throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } if (user.password) { throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } if (!user.token_creation_mdp_expire_le || user.token_creation_mdp_expire_le <= new Date()) { // Invalidation explicite d'un token expiré pour éviter toute réutilisation ultérieure. await this.usersRepo .createQueryBuilder() .update(Users) .set({ token_creation_mdp: () => 'NULL', token_creation_mdp_expire_le: () => 'NULL', }) .where('id = :id', { id: user.id }) .execute(); throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } return { valid: true, message: 'Token valide', }; } async createPasswordWithToken(token: string, plainPassword: string) { const cleanedToken = token?.trim(); if (!cleanedToken) { throw new BadRequestException('Token manquant'); } const existingUser = await this.usersRepo.findOne({ where: { token_creation_mdp: cleanedToken, }, select: ['id', 'password', 'token_creation_mdp_expire_le'], }); if (!existingUser) { throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } if (existingUser.password) { throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } if ( !existingUser.token_creation_mdp_expire_le || existingUser.token_creation_mdp_expire_le <= new Date() ) { await this.usersRepo .createQueryBuilder() .update(Users) .set({ token_creation_mdp: () => 'NULL', token_creation_mdp_expire_le: () => 'NULL', }) .where('id = :id', { id: existingUser.id }) .execute(); throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } const sel = await bcrypt.genSalt(12); const hashedPassword = await bcrypt.hash(plainPassword, sel); // Usage unique atomique : une seule requête UPDATE avec garde-fous. const updateResult = await this.usersRepo .createQueryBuilder() .update(Users) .set({ password: hashedPassword, token_creation_mdp: () => 'NULL', token_creation_mdp_expire_le: () => 'NULL', statut: StatutUtilisateurType.ACTIF, changement_mdp_obligatoire: false, }) .where('token_creation_mdp = :token', { token: cleanedToken }) .andWhere('token_creation_mdp_expire_le > NOW()') .andWhere('password IS NULL') .execute(); if (!updateResult.affected) { this.logger.warn( '[createPasswordWithToken] Token non consommé (course concurrente ou token invalidé)', ); throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } this.logger.log('[createPasswordWithToken] Mot de passe initial créé avec succès'); return { message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.', userId: existingUser.id, }; } /** Réponse unique pour POST forgot-password (anti-énumération), ticket #127 */ private static readonly FORGOT_PASSWORD_GENERIC_MESSAGE = 'Si cette adresse est associée à un compte pour lequel un mot de passe existe, vous recevrez un e-mail avec les instructions.'; /** * Ticket #127 — demande de réinitialisation (e-mail avec lien /reset-password). * Toujours la même réponse 200 ; n’utilise pas token_creation_mdp (#118 inscription). */ async requestPasswordReset(rawEmail: string): Promise<{ message: string }> { const generic = { message: AuthService.FORGOT_PASSWORD_GENERIC_MESSAGE }; const email = (rawEmail ?? '').trim().toLowerCase(); if (!email) { return generic; } const user = await this.usersRepo .createQueryBuilder('u') .where('LOWER(TRIM(u.email)) = :email', { email }) .select(['u.id', 'u.email', 'u.prenom', 'u.nom', 'u.password']) .getOne(); if (!user?.password) { return generic; } const expiryDaysRaw = this.appConfigService.get('password_reset_token_expiry_days', 7); const expiryDays = Number(expiryDaysRaw) > 0 ? Number(expiryDaysRaw) : 7; const expires = new Date(); expires.setDate(expires.getDate() + expiryDays); const token = crypto.randomUUID(); await this.usersRepo.update( { id: user.id }, { password_reset_token: token, password_reset_expires: expires, }, ); try { await this.mailService.sendPasswordResetEmail({ email: user.email, prenom: user.prenom ?? '', nom: user.nom ?? '', token, }); } catch (err) { this.logger.error( '[requestPasswordReset] Échec envoi e-mail (réponse inchangée pour anti-énumération)', err, ); } return generic; } /** * Ticket #127 — consomme password_reset_token (distinct de create-password / token_creation_mdp). */ async resetPasswordWithResetToken(token: string, plainPassword: string): Promise<{ message: string }> { const cleanedToken = token?.trim(); if (!cleanedToken) { throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } const existingUser = await this.usersRepo.findOne({ where: { password_reset_token: cleanedToken }, select: ['id', 'password', 'password_reset_expires'], }); if (!existingUser?.password) { throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } if (!existingUser.password_reset_expires || existingUser.password_reset_expires <= new Date()) { await this.usersRepo .createQueryBuilder() .update(Users) .set({ password_reset_token: () => 'NULL', password_reset_expires: () => 'NULL', }) .where('id = :id', { id: existingUser.id }) .execute(); throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } const sel = await bcrypt.genSalt(12); const hashedPassword = await bcrypt.hash(plainPassword, sel); const updateResult = await this.usersRepo .createQueryBuilder() .update(Users) .set({ password: hashedPassword, password_reset_token: () => 'NULL', password_reset_expires: () => 'NULL', changement_mdp_obligatoire: false, }) .where('password_reset_token = :token', { token: cleanedToken }) .andWhere('password_reset_expires > NOW()') .execute(); if (!updateResult.affected) { this.logger.warn( '[resetPasswordWithResetToken] Token non consommé (course concurrente ou token invalidé)', ); throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } this.logger.log('[resetPasswordWithResetToken] Mot de passe réinitialisé avec succès'); return { message: 'Mot de passe mis à jour. Vous pouvez vous connecter.', }; } /** * Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM() * @deprecated */ async register(registerDto: RegisterDto) { const exists = await this.usersService.findByEmailOrNull(registerDto.email); if (exists) { throw new ConflictException('Email déjà utilisé'); } const allowedRoles = new Set([RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE]); if (!allowedRoles.has(registerDto.role)) { registerDto.role = RoleType.PARENT; } registerDto.statut = StatutUtilisateurType.EN_ATTENTE; if (!registerDto.consentement_photo) { registerDto.date_consentement_photo = null; } else if (registerDto.date_consentement_photo) { const date = new Date(registerDto.date_consentement_photo); if (isNaN(date.getTime())) { registerDto.date_consentement_photo = null; } } const user = await this.usersService.createUser(registerDto); const tokens = await this.generateTokens(user.id, user.email, user.role); return { ...tokens, user: { id: user.id, email: user.email, role: user.role, prenom: user.prenom, nom: user.nom, statut: user.statut, }, }; } /** * Cœur partagé création dossier parent (#129). * - public : statut en_attente + mails pending * - staff : statut actif + mails création MDP (pas de mail « dossier en attente ») */ async createParentDossier( dto: RegisterParentCompletDto, options: { statut: StatutUtilisateurType; sendPendingEmail: boolean; sendPasswordSetupEmail: boolean; requireCgu: boolean; }, ) { if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) { throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire'); } if (!dto.enfants || dto.enfants.length === 0) { throw new BadRequestException('Au moins un enfant est requis'); } const existe = await this.usersService.findByEmailOrNull(dto.email); if (existe) { throw new ConflictException('Un compte avec cet email existe déjà'); } if (dto.co_parent_email) { if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) { throw new BadRequestException( 'L\'email du parent et du co-parent doivent être différents.', ); } const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email); if (coParentExiste) { throw new ConflictException('L\'email du co-parent est déjà utilisé'); } } const joursExpirationToken = await this.appConfigService.get( 'password_reset_token_expiry_days', 7, ); const tokenCreationMdp = crypto.randomUUID(); const dateExpiration = new Date(); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); let resultat: { parent1: Users; parent2: Users | null; enfants: Children[]; tokenCreationMdp: string; tokenCoParent: string | null; }; try { resultat = await this.usersRepo.manager.transaction(async (manager) => { const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const parent1 = manager.create(Users, { email: dto.email, prenom: dto.prenom, nom: dto.nom, role: RoleType.PARENT, statut: options.statut, telephone: dto.telephone, adresse: dto.adresse, code_postal: dto.code_postal, ville: dto.ville, token_creation_mdp: tokenCreationMdp, token_creation_mdp_expire_le: dateExpiration, numero_dossier: numeroDossier, }); const parent1Enregistre = await manager.save(Users, parent1); let parent2Enregistre: Users | null = null; let tokenCoParent: string | null = null; if (dto.co_parent_email && dto.co_parent_prenom && dto.co_parent_nom) { tokenCoParent = crypto.randomUUID(); const dateExpirationCoParent = new Date(); dateExpirationCoParent.setDate(dateExpirationCoParent.getDate() + joursExpirationToken); const parent2 = manager.create(Users, { email: dto.co_parent_email, prenom: dto.co_parent_prenom, nom: dto.co_parent_nom, role: RoleType.PARENT, statut: options.statut, telephone: dto.co_parent_telephone, adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse, code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal, ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville, token_creation_mdp: tokenCoParent, token_creation_mdp_expire_le: dateExpirationCoParent, numero_dossier: numeroDossier, }); parent2Enregistre = await manager.save(Users, parent2); } const entiteParent = manager.create(Parents, { user_id: parent1Enregistre.id, numero_dossier: numeroDossier, }); entiteParent.user = parent1Enregistre; if (parent2Enregistre) { entiteParent.co_parent = parent2Enregistre; } await manager.save(Parents, entiteParent); if (parent2Enregistre) { const entiteCoParent = manager.create(Parents, { user_id: parent2Enregistre.id, numero_dossier: numeroDossier, }); entiteCoParent.user = parent2Enregistre; entiteCoParent.co_parent = parent1Enregistre; await manager.save(Parents, entiteCoParent); } const enfantsEnregistres: Children[] = []; for (const enfantDto of dto.enfants) { let urlPhoto: string | null = null; if (enfantDto.photo_base64 && enfantDto.photo_filename) { urlPhoto = await this.sauvegarderPhotoDepuisBase64( enfantDto.photo_base64, enfantDto.photo_filename, ); } const enfant = new Children(); enfant.first_name = enfantDto.prenom; enfant.last_name = enfantDto.nom || dto.nom; enfant.gender = enfantDto.genre; enfant.birth_date = enfantDto.date_naissance ? new Date(enfantDto.date_naissance) : undefined; enfant.due_date = enfantDto.date_previsionnelle_naissance ? new Date(enfantDto.date_previsionnelle_naissance) : undefined; enfant.photo_url = urlPhoto || undefined; enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.consent_photo = !!enfantDto.consent_photo; enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!; enfant.is_multiple = enfantDto.grossesse_multiple || false; const enfantEnregistre = await manager.save(Children, enfant); enfantsEnregistres.push(enfantEnregistre); const lienParentEnfant1 = manager.create(ParentsChildren, { parentId: parent1Enregistre.id, enfantId: enfantEnregistre.id, }); await manager.save(ParentsChildren, lienParentEnfant1); if (parent2Enregistre) { const lienParentEnfant2 = manager.create(ParentsChildren, { parentId: parent2Enregistre.id, enfantId: enfantEnregistre.id, }); await manager.save(ParentsChildren, lienParentEnfant2); } } // Dossier famille : motivation (texte_motivation côté GET) + liaisons enfants (ticket #119) const presentationTrim = dto.presentation_dossier?.trim(); const dossierFamilleEnt = manager.create(DossierFamille, { numero_dossier: numeroDossier, presentation: presentationTrim || undefined, statut: StatutDossierType.ENVOYE, parent: entiteParent, }); const dossierFamilleSaved = await manager.save(DossierFamille, dossierFamilleEnt); for (const enfantEnregistre of enfantsEnregistres) { await manager.save( DossierFamilleEnfant, manager.create(DossierFamilleEnfant, { id_dossier_famille: dossierFamilleSaved.id, id_enfant: enfantEnregistre.id, }), ); } return { parent1: parent1Enregistre, parent2: parent2Enregistre, enfants: enfantsEnregistres, tokenCreationMdp, tokenCoParent, }; }); } catch (err) { if (this.isPostgresUniqueViolation(err)) { throw new ConflictException( 'Un compte avec cet email existe déjà (contrainte unique en base).', ); } throw err; } const numeroDossier = resultat.parent1.numero_dossier ?? ''; if (options.sendPendingEmail) { try { await this.mailService.sendRegistrationPendingEmail( resultat.parent1.email, resultat.parent1.prenom ?? '', resultat.parent1.nom ?? '', numeroDossier, ); if (resultat.parent2) { await this.mailService.sendRegistrationPendingEmail( resultat.parent2.email, resultat.parent2.prenom ?? '', resultat.parent2.nom ?? '', numeroDossier, ); } } catch (err) { this.logger.error( "[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)", err instanceof Error ? err.stack : String(err), ); } } if (options.sendPasswordSetupEmail) { try { await this.mailService.sendValidatedAccountPasswordSetupEmail( { email: resultat.parent1.email, prenom: resultat.parent1.prenom ?? '', nom: resultat.parent1.nom ?? '', token: resultat.tokenCreationMdp, numeroDossier, }, 'parent', ); if (resultat.parent2 && resultat.tokenCoParent) { await this.mailService.sendValidatedAccountPasswordSetupEmail( { email: resultat.parent2.email, prenom: resultat.parent2.prenom ?? '', nom: resultat.parent2.nom ?? '', token: resultat.tokenCoParent, numeroDossier, }, 'parent', ); } } catch (err) { this.logger.error( '[createParentDossier] Échec envoi email création MDP (dossier conservé)', err instanceof Error ? err.stack : String(err), ); } } const message = options.sendPasswordSetupEmail ? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.' : 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.'; return { message, parent_id: resultat.parent1.id, parent_user_id: resultat.parent1.id, co_parent_id: resultat.parent2?.id ?? null, co_parent_user_id: resultat.parent2?.id ?? null, enfants_ids: resultat.enfants.map(e => e.id), enfant_ids: resultat.enfants.map(e => e.id), statut: options.statut, numero_dossier: numeroDossier, }; } /** * Inscription Parent publique — CDC (statut en_attente + mail pending). */ async inscrireParentComplet(dto: RegisterParentCompletDto) { return this.createParentDossier(dto, { statut: StatutUtilisateurType.EN_ATTENTE, sendPendingEmail: true, sendPasswordSetupEmail: false, requireCgu: true, }); } /** * Création dossier parent par le staff (#129) — actif + mail création MDP. */ async createParentDossierStaff(dto: RegisterParentCompletDto) { return this.createParentDossier(dto, { statut: StatutUtilisateurType.ACTIF, sendPendingEmail: false, sendPasswordSetupEmail: true, requireCgu: false, }); } /** * Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135. * Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer. */ async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) { const pivotParent = await this.parentsRepo.findOne({ where: { user_id: pivotUserId }, relations: ['user', 'co_parent', 'parentChildren'], }); if (!pivotParent?.user) { throw new NotFoundException('Parent introuvable'); } if (pivotParent.co_parent) { throw new BadRequestException('Ce foyer a déjà un co-parent.'); } const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim(); if (!numeroDossier) { throw new BadRequestException("Ce parent n'a pas de numéro de dossier."); } const sameDossierCount = await this.parentsRepo.count({ where: { numero_dossier: numeroDossier }, }); if (sameDossierCount >= 2) { throw new BadRequestException('Ce dossier a déjà deux responsables.'); } const email = dto.email.trim().toLowerCase(); if (pivotParent.user.email.trim().toLowerCase() === email) { throw new BadRequestException( "L'email du co-parent doit être différent de celui du parent principal.", ); } const emailExiste = await this.usersService.findByEmailOrNull(dto.email); if (emailExiste) { throw new ConflictException("L'email du co-parent est déjà utilisé"); } const memeAdresse = dto.meme_adresse ?? true; if (!memeAdresse) { if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) { throw new BadRequestException( "Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.", ); } } const joursExpirationToken = await this.appConfigService.get( 'password_reset_token_expiry_days', 7, ); const tokenCreationMdp = crypto.randomUUID(); const dateExpiration = new Date(); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); let coParent: Users; try { coParent = await this.usersRepo.manager.transaction(async (manager) => { const pivotUser = await manager.findOne(Users, { where: { id: pivotUserId }, }); if (!pivotUser) { throw new NotFoundException('Parent introuvable'); } const pivotEntite = await manager.findOne(Parents, { where: { user_id: pivotUserId }, relations: ['parentChildren'], }); if (!pivotEntite) { throw new NotFoundException('Parent introuvable'); } const coUser = manager.create(Users, { email: dto.email.trim(), prenom: dto.prenom, nom: dto.nom, role: RoleType.PARENT, statut: StatutUtilisateurType.ACTIF, telephone: dto.telephone, adresse: memeAdresse ? pivotUser.adresse : dto.adresse, code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal, ville: memeAdresse ? pivotUser.ville : dto.ville, token_creation_mdp: tokenCreationMdp, token_creation_mdp_expire_le: dateExpiration, numero_dossier: numeroDossier, }); const coUserSaved = await manager.save(Users, coUser); pivotEntite.co_parent = coUserSaved; pivotEntite.numero_dossier = numeroDossier; await manager.save(Parents, pivotEntite); const coEntite = manager.create(Parents, { user_id: coUserSaved.id, numero_dossier: numeroDossier, }); coEntite.user = coUserSaved; coEntite.co_parent = pivotUser; await manager.save(Parents, coEntite); const enfantIds = (pivotEntite.parentChildren ?? []) .map((pc) => pc.enfantId) .filter(Boolean); for (const enfantId of enfantIds) { const existing = await manager.findOne(ParentsChildren, { where: { parentId: coUserSaved.id, enfantId }, }); if (existing) continue; await manager.save( ParentsChildren, manager.create(ParentsChildren, { parentId: coUserSaved.id, enfantId, }), ); } return coUserSaved; }); } catch (err) { if (this.isPostgresUniqueViolation(err)) { throw new ConflictException( 'Un compte avec cet email existe déjà (contrainte unique en base).', ); } throw err; } try { await this.mailService.sendValidatedAccountPasswordSetupEmail( { email: coParent.email, prenom: coParent.prenom ?? '', nom: coParent.nom ?? '', token: tokenCreationMdp, numeroDossier, }, 'parent', ); } catch (err) { this.logger.error( '[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)', err instanceof Error ? err.stack : String(err), ); } return { message: 'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.', numero_dossier: numeroDossier, parent_user_id: pivotUserId, co_parent_user_id: coParent.id, statut: StatutUtilisateurType.ACTIF, }; } /** * Cœur partagé création dossier AM (#156). * - public : statut en_attente + mail pending * - staff : statut actif + mail création MDP */ async createAmDossier( dto: RegisterAMCompletDto, options: { statut: StatutUtilisateurType; sendPendingEmail: boolean; sendPasswordSetupEmail: boolean; requireCgu: boolean; logContext?: string; }, ): Promise<{ message: string; user_id: string; statut: StatutUtilisateurType; numero_dossier: string; }> { const logCtx = options.logContext ?? 'createAmDossier'; if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) { throw new BadRequestException( "L'acceptation des CGU et de la politique de confidentialité est obligatoire", ); } if (dto.places_disponibles > dto.capacite_accueil) { throw new BadRequestException( 'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.', ); } const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase(); const nirValidation = validateNir(nirNormalized, { dateNaissance: dto.date_naissance, }); if (!nirValidation.valid) { throw new BadRequestException(nirValidation.error || 'NIR invalide'); } if (nirValidation.warning) { console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email); } const existe = await this.usersService.findByEmailOrNull(dto.email); if (existe) { throw new ConflictException('Un compte avec cet email existe déjà'); } const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({ where: { nir: nirNormalized }, }); if (nirDejaUtilise) { throw new ConflictException( 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', ); } const numeroAgrement = (dto.numero_agrement || '').trim(); if (numeroAgrement) { const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({ where: { approval_number: numeroAgrement }, }); if (agrementDejaUtilise) { throw new ConflictException( 'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.', ); } } const joursExpirationToken = await this.appConfigService.get( 'password_reset_token_expiry_days', 7, ); const tokenCreationMdp = crypto.randomUUID(); const dateExpiration = new Date(); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); let urlPhoto: string | null = null; const photoB64 = (dto.photo_base64 ?? '').trim(); if (photoB64) { const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg'; urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier); } const dateConsentementPhoto = dto.consentement_photo ? new Date() : undefined; let resultat: { user: Users }; try { resultat = await this.usersRepo.manager.transaction(async (manager) => { const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const user = manager.create(Users, { email: dto.email, prenom: dto.prenom, nom: dto.nom, role: RoleType.ASSISTANTE_MATERNELLE, statut: options.statut, telephone: dto.telephone, adresse: dto.adresse, code_postal: dto.code_postal, ville: dto.ville, token_creation_mdp: tokenCreationMdp, token_creation_mdp_expire_le: dateExpiration, photo_url: urlPhoto ?? undefined, consentement_photo: dto.consentement_photo, date_consentement_photo: dateConsentementPhoto, date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, lieu_naissance_ville: dto.lieu_naissance_ville, lieu_naissance_pays: dto.lieu_naissance_pays, numero_dossier: numeroDossier, }); const userEnregistre = await manager.save(Users, user); const amRepo = manager.getRepository(AssistanteMaternelle); const am = amRepo.create({ user_id: userEnregistre.id, approval_number: dto.numero_agrement, nir: nirNormalized, max_children: dto.capacite_accueil, places_available: dto.places_disponibles, biography: dto.biographie, residence_city: dto.ville ?? undefined, agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, available: true, numero_dossier: numeroDossier, }); await amRepo.save(am); return { user: userEnregistre }; }); } catch (err) { if (this.isPostgresUniqueViolation(err)) { throw new ConflictException( 'Un compte avec cet email existe déjà (contrainte unique en base).', ); } throw err; } const numeroDossier = resultat.user.numero_dossier ?? ''; if (options.sendPendingEmail) { try { await this.mailService.sendRegistrationPendingEmail( resultat.user.email, resultat.user.prenom ?? '', resultat.user.nom ?? '', numeroDossier, ); } catch (err) { this.logger.error( `[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`, err instanceof Error ? err.stack : String(err), ); } } if (options.sendPasswordSetupEmail) { try { await this.mailService.sendValidatedAccountPasswordSetupEmail( { email: resultat.user.email, prenom: resultat.user.prenom ?? '', nom: resultat.user.nom ?? '', token: tokenCreationMdp, numeroDossier, }, 'am', ); } catch (err) { this.logger.error( `[${logCtx}] Échec envoi email création MDP (dossier conservé)`, err instanceof Error ? err.stack : String(err), ); } } const message = options.statut === StatutUtilisateurType.ACTIF ? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.' : 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.'; return { message, user_id: resultat.user.id, statut: options.statut, numero_dossier: numeroDossier, }; } /** * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) * Crée User (role AM) + entrée assistantes_maternelles, token création MDP */ async inscrireAMComplet(dto: RegisterAMCompletDto) { return this.createAmDossier(dto, { statut: StatutUtilisateurType.EN_ATTENTE, sendPendingEmail: true, sendPasswordSetupEmail: false, requireCgu: true, logContext: 'inscrireAMComplet', }); } /** * Création dossier AM par staff (#156) — statut actif + e-mail création MDP. */ async createAmDossierStaff(dto: RegisterAMCompletDto) { return this.createAmDossier(dto, { statut: StatutUtilisateurType.ACTIF, sendPendingEmail: false, sendPasswordSetupEmail: true, requireCgu: false, logContext: 'createAmDossierStaff', }); } /** * Sauvegarde une photo depuis base64 vers le système de fichiers */ /** * Sauvegarde une photo depuis base64 vers le système de fichiers */ private async sauvegarderPhotoDepuisBase64(donneesBase64: string, nomFichier: string): Promise { const correspondances = donneesBase64.match(/^data:image\/(\w+);base64,(.+)$/); if (!correspondances) { throw new BadRequestException('Format de photo invalide (doit être base64)'); } const extension = correspondances[1]; const tamponImage = Buffer.from(correspondances[2], 'base64'); const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim(); const dossierUpload = rawDir ? path.isAbsolute(rawDir) ? rawDir : path.resolve(process.cwd(), rawDir) : path.join(process.cwd(), 'uploads', 'photos'); await fs.mkdir(dossierUpload, { recursive: true }); const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`; const cheminFichier = path.join(dossierUpload, nomFichierUnique); await fs.writeFile(cheminFichier, tamponImage); return `/uploads/photos/${nomFichierUnique}`; } /** * Changement de mot de passe obligatoire (première connexion) */ async changePasswordRequired( userId: string, motDePasseActuel: string, nouveauMotDePasse: string, ) { const user = await this.usersRepo.findOne({ where: { id: userId } }); if (!user) { throw new UnauthorizedException('Utilisateur introuvable'); } // Vérifier que le changement est bien obligatoire if (!user.changement_mdp_obligatoire) { throw new BadRequestException( 'Le changement de mot de passe n\'est pas requis pour cet utilisateur', ); } // Vérifier que l'utilisateur a un mot de passe if (!user.password) { throw new BadRequestException('Compte non activé'); } // Vérifier le mot de passe actuel const motDePasseValide = await bcrypt.compare(motDePasseActuel, user.password); if (!motDePasseValide) { throw new BadRequestException('Mot de passe actuel incorrect'); } // Vérifier que le nouveau mot de passe est différent de l'ancien const memeMotDePasse = await bcrypt.compare(nouveauMotDePasse, user.password); if (memeMotDePasse) { throw new BadRequestException( 'Le nouveau mot de passe doit être différent de l\'ancien', ); } // Hasher et sauvegarder le nouveau mot de passe const sel = await bcrypt.genSalt(12); user.password = await bcrypt.hash(nouveauMotDePasse, sel); user.changement_mdp_obligatoire = false; user.modifie_le = new Date(); await this.usersRepo.save(user); return { success: true, message: 'Mot de passe changé avec succès', }; } async logout(userId: string) { return { success: true, message: 'Deconnexion'} } /** GET dossier reprise – token seul. Ticket #111, enrichi #112 */ async getRepriseDossier(token: string): Promise { const user = await this.usersService.findByTokenReprise(token); if (!user) { throw new NotFoundException('Token reprise invalide ou expiré.'); } const base = this.userToRepriseBase(user); if (user.role === RoleType.PARENT && user.numero_dossier) { try { const famille = await this.parentsService.getDossierFamilleByNumero(user.numero_dossier); return { ...base, parents: famille.parents, enfants: famille.enfants, texte_motivation: famille.texte_motivation, }; } catch (err) { if (err instanceof NotFoundException) { return base; } throw err; } } if (user.role === RoleType.ASSISTANTE_MATERNELLE && user.numero_dossier) { try { const unifie = await this.dossiersService.getDossierByNumero(user.numero_dossier); if (unifie.type === 'am') { return this.mergeAmRepriseDossier(base, unifie.dossier as DossierAmCompletDto); } } catch (err) { if (err instanceof NotFoundException) { return base; } throw err; } } return base; } /** PATCH resoumission reprise – dossier complet. Ticket #111, enrichi #112 */ async resoumettreReprise(dto: ResoumettreRepriseDto): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string; }> { const user = await this.usersService.findByTokenReprise(dto.token); if (!user) { throw new NotFoundException('Token reprise invalide ou expiré.'); } if (user.role === RoleType.PARENT) { return this.resoumettreRepriseParent(user, dto); } if (user.role === RoleType.ASSISTANTE_MATERNELLE) { return this.resoumettreRepriseAM(user, dto); } throw new BadRequestException('Rôle non pris en charge pour la reprise.'); } private userToRepriseBase(user: Users): RepriseDossierDto { return { id: user.id, email: user.email, prenom: user.prenom, nom: user.nom, telephone: user.telephone, adresse: user.adresse, ville: user.ville, code_postal: user.code_postal, numero_dossier: user.numero_dossier, role: user.role, photo_url: user.photo_url, genre: user.genre, situation_familiale: user.situation_familiale, }; } private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto { const u = dossier.user; return { ...base, email: u.email, prenom: u.prenom, nom: u.nom, telephone: u.telephone, adresse: u.adresse, ville: u.ville, code_postal: u.code_postal, photo_url: u.photo_url, consentement_photo: u.consentement_photo, date_naissance: u.date_naissance, lieu_naissance_ville: u.lieu_naissance_ville, lieu_naissance_pays: u.lieu_naissance_pays, numero_agrement: dossier.numero_agrement, nir: dossier.nir, date_agrement: dossier.date_agrement, nb_max_enfants: dossier.nb_max_enfants, place_disponible: dossier.place_disponible, biographie: dossier.biographie, }; } private applyTitulaireUserFields(user: Users, dto: ResoumettreRepriseDto): void { 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; } private async resoumettreRepriseParent( titulaire: Users, dto: ResoumettreRepriseDto, ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { const motivation = dto.texte_motivation?.trim() ?? dto.presentation_dossier?.trim(); const result = await this.usersRepo.manager.transaction(async (manager) => { const familyUsers = titulaire.numero_dossier ? await manager.find(Users, { where: { role: RoleType.PARENT, numero_dossier: titulaire.numero_dossier, }, }) : [titulaire]; const titulaireLive = familyUsers.find((u) => u.id === titulaire.id) ?? titulaire; this.applyTitulaireUserFields(titulaireLive, dto); const coParentUser = familyUsers.find((u) => u.id !== titulaire.id); if (coParentUser) { if (dto.co_parent_prenom !== undefined) coParentUser.prenom = dto.co_parent_prenom; if (dto.co_parent_nom !== undefined) coParentUser.nom = dto.co_parent_nom; if (dto.co_parent_telephone !== undefined) coParentUser.telephone = dto.co_parent_telephone; if (dto.co_parent_meme_adresse) { coParentUser.adresse = titulaireLive.adresse; coParentUser.code_postal = titulaireLive.code_postal; coParentUser.ville = titulaireLive.ville; } else { if (dto.co_parent_adresse !== undefined) coParentUser.adresse = dto.co_parent_adresse; if (dto.co_parent_code_postal !== undefined) coParentUser.code_postal = dto.co_parent_code_postal; if (dto.co_parent_ville !== undefined) coParentUser.ville = dto.co_parent_ville; } } if (titulaire.numero_dossier && motivation !== undefined) { const dossierFamille = await manager.findOne(DossierFamille, { where: { numero_dossier: titulaire.numero_dossier }, }); if (dossierFamille) { dossierFamille.presentation = motivation || undefined; await manager.save(DossierFamille, dossierFamille); } } if (dto.enfants?.length) { const familyChildIds = await this.getFamilyChildrenIds(manager, titulaireLive.id); for (const enfantDto of dto.enfants) { if (!familyChildIds.has(enfantDto.id)) { throw new BadRequestException( `Enfant inconnu pour ce dossier : ${enfantDto.id}`, ); } const enfant = await manager.findOne(Children, { where: { id: enfantDto.id } }); if (!enfant) { throw new BadRequestException(`Enfant introuvable : ${enfantDto.id}`); } if (enfantDto.prenom !== undefined) enfant.first_name = enfantDto.prenom; if (enfantDto.nom !== undefined) enfant.last_name = enfantDto.nom; if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre; if (enfantDto.date_naissance !== undefined) { enfant.birth_date = new Date(enfantDto.date_naissance); enfant.status = StatutEnfantType.SANS_GARDE; } if (enfantDto.date_previsionnelle_naissance !== undefined) { enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); if (!enfantDto.date_naissance) { enfant.status = StatutEnfantType.A_NAITRE; } } if (enfantDto.grossesse_multiple !== undefined) { enfant.is_multiple = enfantDto.grossesse_multiple; } if (enfantDto.consent_photo !== undefined) { enfant.consent_photo = !!enfantDto.consent_photo; enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!; } if (enfantDto.photo_base64 && enfantDto.photo_filename) { enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( enfantDto.photo_base64, enfantDto.photo_filename, ); } await manager.save(Children, enfant); } } for (const familyUser of familyUsers) { familyUser.statut = StatutUtilisateurType.EN_ATTENTE; familyUser.token_reprise = undefined; familyUser.token_reprise_expire_le = undefined; await manager.save(Users, familyUser); } return { message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', statut: StatutUtilisateurType.EN_ATTENTE, user_id: titulaireLive.id, numero_dossier: titulaireLive.numero_dossier, parentsPourEmail: familyUsers, }; }); const numeroDossier = result.numero_dossier ?? ''; try { for (const parent of result.parentsPourEmail) { await this.mailService.sendResoumissionPendingEmail( parent.email, parent.prenom ?? '', parent.nom ?? '', numeroDossier, ); } } catch (err) { this.logger.error( "[resoumettreRepriseParent] Échec envoi email accusé resoumission (dossier conservé)", err instanceof Error ? err.stack : String(err), ); } const { parentsPourEmail: _parentsPourEmail, ...response } = result; return response; } private async resoumettreRepriseAM( user: Users, dto: ResoumettreRepriseDto, ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { if ( dto.capacite_accueil !== undefined && dto.places_disponibles !== undefined && dto.places_disponibles > dto.capacite_accueil ) { throw new BadRequestException( 'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.', ); } let urlPhoto: string | undefined; const photoB64 = (dto.photo_base64 ?? '').trim(); if (photoB64) { const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg'; urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier); } else if (dto.photo_url !== undefined) { urlPhoto = dto.photo_url; } if (dto.nir !== undefined) { const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); const nirValidation = validateNir(nirNormalized, { dateNaissance: dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10), }); if (!nirValidation.valid) { throw new BadRequestException(nirValidation.error || 'NIR invalide'); } } return this.usersRepo.manager.transaction(async (manager) => { const userLive = await manager.findOne(Users, { where: { id: user.id } }); if (!userLive) { throw new NotFoundException('Utilisateur introuvable.'); } this.applyTitulaireUserFields(userLive, dto); if (urlPhoto !== undefined) userLive.photo_url = urlPhoto; if (dto.consentement_photo !== undefined) { userLive.consentement_photo = dto.consentement_photo; if (dto.consentement_photo) { userLive.date_consentement_photo = new Date(); } } if (dto.date_naissance !== undefined) { userLive.date_naissance = new Date(dto.date_naissance); } if (dto.lieu_naissance_ville !== undefined) { userLive.lieu_naissance_ville = dto.lieu_naissance_ville; } if (dto.lieu_naissance_pays !== undefined) { userLive.lieu_naissance_pays = dto.lieu_naissance_pays; } const am = await manager.findOne(AssistanteMaternelle, { where: { user_id: user.id } }); if (!am) { throw new NotFoundException('Fiche assistante maternelle introuvable.'); } if (dto.nir !== undefined) { const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); const nirDejaUtilise = await manager.findOne(AssistanteMaternelle, { where: { nir: nirNormalized }, }); if (nirDejaUtilise && nirDejaUtilise.user_id !== user.id) { throw new ConflictException( 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', ); } am.nir = nirNormalized; } if (dto.numero_agrement !== undefined) { const agrement = dto.numero_agrement.trim(); const agrementDejaUtilise = await manager.findOne(AssistanteMaternelle, { where: { approval_number: agrement }, }); if (agrementDejaUtilise && agrementDejaUtilise.user_id !== user.id) { throw new ConflictException( 'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.', ); } am.approval_number = agrement; } if (dto.date_agrement !== undefined) { am.agreement_date = new Date(dto.date_agrement); } if (dto.capacite_accueil !== undefined) { am.max_children = dto.capacite_accueil; } if (dto.places_disponibles !== undefined) { am.places_available = dto.places_disponibles; } if (dto.biographie !== undefined) { am.biography = dto.biographie; } userLive.statut = StatutUtilisateurType.EN_ATTENTE; userLive.token_reprise = undefined; userLive.token_reprise_expire_le = undefined; await manager.save(AssistanteMaternelle, am); await manager.save(Users, userLive); return { message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', statut: StatutUtilisateurType.EN_ATTENTE, user_id: userLive.id, numero_dossier: userLive.numero_dossier, }; }); } private async getFamilyChildrenIds( manager: EntityManager, parentUserId: string, ): Promise> { const familyUserIds = await this.parentsService.getFamilyUserIds(parentUserId); const links = await manager.find(ParentsChildren, { where: { parentId: In(familyUserIds) }, }); return new Set(links.map((l) => l.enfantId)); } /** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */ async identifyReprise(numero_dossier: string, email: string): Promise { const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email); if (!user || !user.token_reprise) { throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.'); } return { type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle', token: user.token_reprise, }; } /** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */ private isPostgresUniqueViolation(err: unknown): boolean { if (!(err instanceof QueryFailedError)) { return false; } const code = (err.driverError as { code?: string } | undefined)?.code; return code === '23505'; } }