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 { RegisterParentDto } from './dto/register-parent.dto'; import { RegisterParentCompletDto } from './dto/register-parent-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 { 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 registerParent() 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 (étape 1/6 du workflow CDC) * SANS mot de passe - Token de création MDP généré */ async registerParent(dto: RegisterParentDto) { // 1. Vérifier que l'email n'existe pas const exists = await this.usersService.findByEmailOrNull(dto.email); if (exists) { throw new ConflictException('Un compte avec cet email existe déjà'); } // 2. Vérifier l'email du co-parent s'il existe if (dto.co_parent_email) { const coParentExists = await this.usersService.findByEmailOrNull(dto.co_parent_email); if (coParentExists) { throw new ConflictException('L\'email du co-parent est déjà utilisé'); } } // 3. Récupérer la durée d'expiration du token depuis la config const tokenExpiryDays = await this.appConfigService.get( 'password_reset_token_expiry_days', 7, ); // 4. Générer les tokens de création de mot de passe const tokenCreationMdp = crypto.randomUUID(); const tokenExpiration = new Date(); tokenExpiration.setDate(tokenExpiration.getDate() + tokenExpiryDays); // 5. Transaction : Créer Parent 1 + Parent 2 (si existe) + entités parents const result = await this.usersRepo.manager.transaction(async (manager) => { // Créer Parent 1 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: tokenExpiration, }); const savedParent1 = await manager.save(Users, parent1); // Créer Parent 2 si renseigné let savedParent2: 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 tokenExpirationCoParent = new Date(); tokenExpirationCoParent.setDate(tokenExpirationCoParent.getDate() + tokenExpiryDays); 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: tokenExpirationCoParent, }); savedParent2 = await manager.save(Users, parent2); } // Créer l'entité métier Parents pour Parent 1 const parentEntity = manager.create(Parents, { user_id: savedParent1.id, }); parentEntity.user = savedParent1; if (savedParent2) { parentEntity.co_parent = savedParent2; } await manager.save(Parents, parentEntity); // Créer l'entité métier Parents pour Parent 2 (si existe) if (savedParent2) { const coParentEntity = manager.create(Parents, { user_id: savedParent2.id, }); coParentEntity.user = savedParent2; coParentEntity.co_parent = savedParent1; await manager.save(Parents, coParentEntity); } return { parent1: savedParent1, parent2: savedParent2, tokenCreationMdp, tokenCoParent, }; }); // 6. TODO: Envoyer email avec lien de création de MDP // await this.mailService.sendPasswordCreationEmail(result.parent1, result.tokenCreationMdp); // if (result.parent2 && result.tokenCoParent) { // await this.mailService.sendPasswordCreationEmail(result.parent2, result.tokenCoParent); // } return { message: 'Inscription réussie. Un email de validation vous a été envoyé.', parent_id: result.parent1.id, co_parent_id: result.parent2?.id, statut: StatutUtilisateurType.EN_ATTENTE, }; } /** * 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, }; } /** * 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}`; } async logout(userId: string) { return { success: true, message: 'Deconnexion'} } }