import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Repository } from 'typeorm'; import { RoleType, Users } from 'src/entities/users.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AmChildren } from 'src/entities/am_children.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; import { validateNir } from 'src/common/utils/nir.util'; const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const; @Injectable() export class AssistantesMaternellesService { constructor( @InjectRepository(AssistanteMaternelle) private readonly assistantesMaternelleRepository: Repository, @InjectRepository(Users) private readonly usersRepository: Repository, @InjectRepository(AmChildren) private readonly amChildrenRepository: Repository, @InjectRepository(Children) private readonly childrenRepository: Repository, ) {} async create(dto: CreateAssistanteDto): Promise { const user = await this.usersRepository.findOneBy({ id: dto.user_id }); if (!user) throw new NotFoundException('Utilisateur introuvable'); if (user.role !== RoleType.ASSISTANTE_MATERNELLE) { throw new BadRequestException('Accès réservé aux assistantes maternelles'); } const exist = await this.assistantesMaternelleRepository.findOneBy({ user_id: dto.user_id }); if (exist) throw new ConflictException('Assistante maternelle déjà existante'); const entity = this.assistantesMaternelleRepository.create({ user_id: dto.user_id, user: { ...user, role: RoleType.ASSISTANTE_MATERNELLE }, approval_number: dto.approval_number, nir: dto.nir, max_children: dto.max_children, biography: dto.biography, available: dto.available ?? true, residence_city: dto.residence_city, agreement_date: dto.agreement_date ? new Date(dto.agreement_date) : undefined, years_experience: dto.years_experience, specialty: dto.specialty, places_available: dto.places_available, }); return this.assistantesMaternelleRepository.save(entity); } async findAll(): Promise { return this.assistantesMaternelleRepository.find({ relations: [...AM_CHILDREN_RELATIONS], }); } async findOne(user_id: string): Promise { const assistante = await this.assistantesMaternelleRepository.findOne({ where: { user_id }, relations: [...AM_CHILDREN_RELATIONS], }); if (!assistante) throw new NotFoundException('Assistante maternelle introuvable'); return assistante; } async update(id: string, dto: UpdateAssistanteDto): Promise { await this.assistantesMaternelleRepository.update(id, dto); return this.findOne(id); } /** * Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131. */ async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise { const am = await this.findOne(amUserId); const user = am.user; if (dto.email && dto.email !== user.email) { const existing = await this.usersRepository.findOne({ where: { email: dto.email } }); if (existing && existing.id !== user.id) { throw new ConflictException('Cet email est déjà utilisé'); } user.email = dto.email; } if (dto.nom !== undefined) user.nom = dto.nom; if (dto.prenom !== undefined) user.prenom = dto.prenom; 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; if (dto.statut !== undefined) user.statut = dto.statut; if (dto.date_naissance !== undefined) { user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined; } if (dto.lieu_naissance_ville !== undefined) { user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined; } if (dto.lieu_naissance_pays !== undefined) { user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined; } await this.usersRepository.save(user); const amPatch: Partial = {}; if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number; if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city; if (dto.max_children !== undefined) amPatch.max_children = dto.max_children; if (dto.places_available !== undefined) amPatch.places_available = dto.places_available; if (dto.biography !== undefined) amPatch.biography = dto.biography; if (dto.available !== undefined) amPatch.available = dto.available; if (dto.agreement_date !== undefined) { amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined; } if (dto.nir !== undefined) { const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); if (nirNormalized) { const dateNaissanceForNir = dto.date_naissance ?? (user.date_naissance instanceof Date ? user.date_naissance.toISOString().slice(0, 10) : user.date_naissance ? String(user.date_naissance).slice(0, 10) : undefined); const nirValidation = validateNir(nirNormalized, { dateNaissance: dateNaissanceForNir, }); if (!nirValidation.valid) { throw new BadRequestException(nirValidation.error || 'NIR invalide'); } const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({ where: { nir: nirNormalized }, }); if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) { throw new ConflictException( 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', ); } amPatch.nir = nirNormalized; } // NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé. } if (Object.keys(amPatch).length > 0) { await this.assistantesMaternelleRepository.update(amUserId, amPatch); } return this.findOne(amUserId); } /** * Rattacher un enfant à une AM (placement actif). Ticket #131. * Passe le statut enfant à `garde` (sauf a_naitre / scolarise). */ async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise { const am = await this.findOne(amUserId); const existingForAm = await this.amChildrenRepository.findOne({ where: { amId: amUserId, enfantId, date_fin: IsNull() }, }); if (existingForAm) { throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle'); } const child = await this.childrenRepository.findOne({ where: { id: enfantId } }); if (!child) { throw new NotFoundException('Enfant introuvable'); } const activeForChild = await this.amChildrenRepository.findOne({ where: { enfantId, date_fin: IsNull() }, }); if (activeForChild && activeForChild.amId !== amUserId) { throw new ConflictException( 'Cet enfant est déjà en garde chez une autre assistante maternelle', ); } const activeCount = await this.amChildrenRepository.count({ where: { amId: amUserId, date_fin: IsNull() }, }); if (am.max_children != null && activeCount >= am.max_children) { throw new BadRequestException( `Capacité maximale atteinte (${am.max_children} enfant(s))`, ); } await this.amChildrenRepository.save( this.amChildrenRepository.create({ amId: amUserId, enfantId, date_debut: new Date(), cree_par: createdBy?.id, }), ); await this.applyGardeStatusOnAttach(child); return this.findOne(amUserId); } /** * Clôturer le placement AM ↔ enfant. Ticket #131. * Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif. */ async detachEnfant(amUserId: string, enfantId: string): Promise { await this.findOne(amUserId); const link = await this.amChildrenRepository.findOne({ where: { amId: amUserId, enfantId, date_fin: IsNull() }, relations: ['child'], }); if (!link) { throw new NotFoundException('Lien assistante maternelle-enfant introuvable'); } link.date_fin = new Date(); await this.amChildrenRepository.save(link); const remaining = await this.amChildrenRepository.count({ where: { enfantId, date_fin: IsNull() }, }); if (remaining === 0 && link.child) { await this.applySansGardeStatusOnDetach(link.child); } return this.findOne(amUserId); } private async applyGardeStatusOnAttach(child: Children): Promise { if ( child.status === StatutEnfantType.A_NAITRE || child.status === StatutEnfantType.SCOLARISE ) { return; } child.status = StatutEnfantType.GARDE; await this.childrenRepository.save(child); } private async applySansGardeStatusOnDetach(child: Children): Promise { if ( child.status === StatutEnfantType.A_NAITRE || child.status === StatutEnfantType.SCOLARISE ) { return; } child.status = StatutEnfantType.SANS_GARDE; await this.childrenRepository.save(child); } async remove(id: string): Promise<{ message: string }> { await this.assistantesMaternelleRepository.delete(id); return { message: 'Assistante maternelle supprimée' }; } }