- Inscription parent: créer dossier_famille + dossier_famille_enfants (presentation_dossier) - Nest: useStaticAssets sur le volume uploads (chemins /uploads/photos/…) - docker-compose: route Traefik PathPrefix /uploads → API (priorité 18) - Front: ParentDossier.id depuis user_id (aligné API) Made-with: Cursor
669 lines
26 KiB
TypeScript
669 lines
26 KiB
TypeScript
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<Parents>,
|
||
@InjectRepository(Users)
|
||
private readonly usersRepo: Repository<Users>,
|
||
@InjectRepository(Children)
|
||
private readonly childrenRepo: Repository<Children>,
|
||
@InjectRepository(AssistanteMaternelle)
|
||
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
|
||
) { }
|
||
|
||
/**
|
||
* Génère un access_token et un refresh_token
|
||
*/
|
||
async generateTokens(userId: string, email: string, role: RoleType) {
|
||
const accessSecret = this.configService.get<string>('jwt.accessSecret');
|
||
const accessExpiresIn = this.configService.get<string>('jwt.accessExpiresIn');
|
||
const refreshSecret = this.configService.get<string>('jwt.refreshSecret');
|
||
const refreshExpiresIn = this.configService.get<string>('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<string>('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>([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<number>(
|
||
'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<number>(
|
||
'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<string> {
|
||
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<RepriseDossierDto> {
|
||
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<Users> {
|
||
return this.usersService.resoumettreReprise(token, dto);
|
||
}
|
||
|
||
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
|
||
async identifyReprise(numero_dossier: string, email: string): Promise<RepriseIdentifyResponseDto> {
|
||
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';
|
||
}
|
||
}
|