import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, 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 { ParentsChildren } from 'src/entities/parents_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 { CouplesGardeAmResponseDto } from './dto/couples-garde-am.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, @InjectRepository(ParentsChildren) private readonly parentsChildrenRepository: 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' }; } /** * Liste les couples de garde (enfant ↔ parents) de l’AM connectée — ticket #171. * Un couple = un placement actif dans enfants_assistantes_maternelles pour cette AM. */ async listerCouplesGarde(amUserId: string): Promise { const am = await this.assistantesMaternelleRepository.findOne({ where: { user_id: amUserId }, }); if (!am) { throw new NotFoundException('Assistante maternelle introuvable'); } const placements = await this.amChildrenRepository.find({ where: { amId: amUserId, date_fin: IsNull() }, relations: ['child'], order: { date_debut: 'ASC' }, }); if (placements.length === 0) { return { couples: [], couple_courant_id: null }; } const enfantIds = placements.map((p) => p.enfantId); const liens = await this.parentsChildrenRepository.find({ where: { enfantId: In(enfantIds) }, relations: ['parent', 'parent.user'], }); const parentsByEnfant = new Map< string, { id: string; prenom: string | null; nom: string | null; photo_url: string | null }[] >(); for (const l of liens) { const list = parentsByEnfant.get(l.enfantId) ?? []; const u = l.parent?.user; list.push({ id: l.parentId, prenom: u?.prenom ?? null, nom: u?.nom ?? null, photo_url: u?.photo_url ?? null, }); parentsByEnfant.set(l.enfantId, list); } let idCourant = am.id_placement_garde_courant ?? null; const idsValides = new Set(placements.map((p) => p.id)); if (idCourant && !idsValides.has(idCourant)) { idCourant = null; await this.assistantesMaternelleRepository.update( { user_id: amUserId }, { id_placement_garde_courant: () => 'NULL' }, ); } if (!idCourant && placements.length === 1) { idCourant = placements[0].id; } const couples = placements.map((p) => ({ id: p.id, enfant: { id: p.child.id, prenom: p.child.first_name ?? null, nom: p.child.last_name ?? null, photo_url: p.child.photo_url ?? null, }, parents: parentsByEnfant.get(p.enfantId) ?? [], courant: idCourant != null && p.id === idCourant, })); return { couples, couple_courant_id: idCourant, }; } /** * Persiste le couple de garde actif pour l’AM — ticket #171. */ async definirCoupleGardeCourant( amUserId: string, coupleId: string, ): Promise { const am = await this.assistantesMaternelleRepository.findOne({ where: { user_id: amUserId }, }); if (!am) { throw new NotFoundException('Assistante maternelle introuvable'); } const placement = await this.amChildrenRepository.findOne({ where: { id: coupleId, amId: amUserId, date_fin: IsNull() }, }); if (!placement) { throw new NotFoundException('Couple de garde introuvable ou inactif pour cette AM'); } await this.assistantesMaternelleRepository.update( { user_id: amUserId }, { id_placement_garde_courant: coupleId }, ); return this.listerCouplesGarde(amUserId); } }