import { ConflictException, Injectable, UnauthorizedException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { 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 { LoginDto } from './dto/login.dto'; import { AppConfigService } from 'src/modules/config/config.service'; @Injectable() export class AuthService { constructor( private readonly usersService: UserService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly appConfigService: AppConfigService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @InjectRepository(Users) private readonly usersRepo: Repository, @InjectRepository(Children) private readonly childrenRepo: 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.'); } 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) { 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); const resultat = await this.usersRepo.manager.transaction(async (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, }); 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, }); parent2Enregistre = await manager.save(Users, parent2); } const entiteParent = manager.create(Parents, { user_id: parent1Enregistre.id, }); 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, }); 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); } } return { parent1: parent1Enregistre, parent2: parent2Enregistre, enfants: enfantsEnregistres, tokenCreationMdp, tokenCoParent, }; }); 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, }; } /** * 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 existe = await this.usersService.findByEmailOrNull(dto.email); if (existe) { throw new ConflictException('Un compte avec cet email 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; const resultat = await this.usersRepo.manager.transaction(async (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, }); 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: dto.nir, 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, }); await amRepo.save(am); return { user: userEnregistre }; }); 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 dossierUpload = '/app/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'} } }