import { ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { 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 { 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'; @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, @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'); } } /** * 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, }, }; } /** * Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction * Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU */ async inscrireParentComplet(dto: RegisterParentCompletDto) { if (!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: StatutUtilisateurType.EN_ATTENTE, 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: StatutUtilisateurType.EN_ATTENTE, 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.ACTIF : StatutEnfantType.A_NAITRE; enfant.consent_photo = false; 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 ?? ''; try { await this.mailService.sendParentRegistrationPendingEmail( resultat.parent1.email, resultat.parent1.prenom ?? '', resultat.parent1.nom ?? '', numeroDossier, ); if (resultat.parent2) { await this.mailService.sendParentRegistrationPendingEmail( resultat.parent2.email, resultat.parent2.prenom ?? '', resultat.parent2.nom ?? '', numeroDossier, ); } } catch (err) { this.logger.error( "[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)", err instanceof Error ? err.stack : String(err), ); } return { message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', parent_id: resultat.parent1.id, co_parent_id: resultat.parent2?.id, enfants_ids: resultat.enfants.map(e => e.id), statut: StatutUtilisateurType.EN_ATTENTE, 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) { if (!dto.acceptation_cgu || !dto.acceptation_privacy) { throw new BadRequestException( "L'acceptation des CGU et de la politique de confidentialité est obligatoire", ); } 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) { // Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse) console.warn('[inscrireAMComplet] 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; if (dto.photo_base64 && dto.photo_filename) { urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename); } 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: StatutUtilisateurType.EN_ATTENTE, 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, 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, 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; } return { message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', user_id: resultat.user.id, statut: StatutUtilisateurType.EN_ATTENTE, }; } /** * 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 */ async getRepriseDossier(token: string): Promise { const user = await this.usersService.findByTokenReprise(token); if (!user) { throw new NotFoundException('Token reprise invalide ou expiré.'); } 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, }; } /** PUT resoumission reprise. Ticket #111 */ async resoumettreReprise( token: string, dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, ): Promise { return this.usersService.resoumettreReprise(token, dto); } /** 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'; } }