Files
petitspas/backend/src/routes/auth/auth.service.ts
T
jmartinandCursor 530e896b66 feat(#135): POST /parents/:id/co-parent — ajout co-parent foyer.
API staff pour foyer mono-parent : compte actif, liens + enfants,
mail création MDP. Mini-specs front/back dans docs/tmp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 23:34:19 +02:00

1595 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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');
}
}
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 ; nutilise 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<number>('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>([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<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: 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<number>(
'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<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;
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<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, enrichi #112 */
async getRepriseDossier(token: string): Promise<RepriseDossierDto> {
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<Set<string>> {
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<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';
}
}