import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { Parents } from 'src/entities/parents.entity'; import { DossierFamille } from 'src/entities/dossier_famille.entity'; import { RoleType, Users } from 'src/entities/users.entity'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { PendingFamilyDto } from './dto/pending-family.dto'; import { DossierFamilleCompletDto, DossierFamilleParentDto, DossierFamilleEnfantDto, } from './dto/dossier-famille-complet.dto'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { Children } from 'src/entities/children.entity'; import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; @Injectable() export class ParentsService { constructor( @InjectRepository(Parents) private readonly parentsRepository: Repository, @InjectRepository(Users) private readonly usersRepository: Repository, @InjectRepository(DossierFamille) private readonly dossierFamilleRepository: Repository, @InjectRepository(ParentsChildren) private readonly parentsChildrenRepository: Repository, ) {} // Création d’un parent async create(dto: CreateParentDto): Promise { const user = await this.usersRepository.findOneBy({ id: dto.user_id }); if (!user) throw new NotFoundException('Utilisateur introuvable'); if (user.role !== RoleType.PARENT) { throw new BadRequestException('Accès réservé aux parents'); } const exist = await this.parentsRepository.findOneBy({ user_id: dto.user_id }); if (exist) throw new ConflictException('Ce parent existe déjà'); let co_parent: Users | null = null; if (dto.co_parent_id) { co_parent = await this.usersRepository.findOneBy({ id: dto.co_parent_id }); if (!co_parent) throw new NotFoundException('Co-parent introuvable'); if (co_parent.role !== RoleType.PARENT) { throw new BadRequestException('Accès réservé aux parents'); } } const entity = this.parentsRepository.create({ user_id: dto.user_id, user, co_parent: co_parent ?? undefined, }); return this.parentsRepository.save(entity); } // Liste des parents async findAll(): Promise { return this.parentsRepository.find({ relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'], }); } // Récupérer un parent par user_id async findOne(user_id: string): Promise { const parent = await this.parentsRepository.findOne({ where: { user_id }, relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'], }); if (!parent) throw new NotFoundException('Parent introuvable'); return parent; } // Mise à jour async update(id: string, dto: UpdateParentsDto): Promise { await this.parentsRepository.update(id, dto); return this.findOne(id); } /** * Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1. */ async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise { const parent = await this.findOne(parentUserId); const user = parent.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; await this.usersRepository.save(user); return this.findOne(parentUserId); } /** * Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2. */ async attachEnfant(parentUserId: string, enfantId: string): Promise { await this.findOne(parentUserId); const existing = await this.parentsChildrenRepository.findOne({ where: { parentId: parentUserId, enfantId }, }); if (existing) { throw new ConflictException('Cet enfant est déjà rattaché à ce parent'); } const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } }); if (!child) { throw new NotFoundException('Enfant introuvable'); } await this.parentsChildrenRepository.save( this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }), ); return this.findOne(parentUserId); } /** * Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2. */ async detachEnfant(parentUserId: string, enfantId: string): Promise { await this.findOne(parentUserId); const link = await this.parentsChildrenRepository.findOne({ where: { parentId: parentUserId, enfantId }, }); if (!link) { throw new NotFoundException('Lien parent-enfant introuvable'); } const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } }); if (totalLinks <= 1) { throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable'); } await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); return this.findOne(parentUserId); } /** * Liste des familles en attente (une entrée par famille). * Famille = lien co_parent ou partage d'enfants (même logique que backfill #103). * Uniquement les parents dont l'utilisateur a statut = en_attente. */ async getPendingFamilies(): Promise { let raw: { libelle: string; parentIds: unknown; numero_dossier: string | null; date_soumission: Date | string | null; nombre_enfants: string | number | null; emails: unknown; parents: unknown; }[]; try { raw = await this.parentsRepository.query(` WITH RECURSIVE links AS ( SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL UNION ALL SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL UNION ALL SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 FROM enfants_parents ep1 JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent UNION ALL SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 FROM enfants_parents ep1 JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent ), rec AS ( SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents UNION SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 ), family_rep AS ( SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id ) SELECT 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle, array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds", (array_agg(p.numero_dossier))[1] AS numero_dossier, MIN(u.cree_le) AS date_soumission, COALESCE(( SELECT COUNT(DISTINCT ep.id_enfant)::int FROM enfants_parents ep WHERE ep.id_parent IN ( SELECT frx.id FROM family_rep frx WHERE frx.rep = fr.rep ) ), 0) AS nombre_enfants, array_agg(u.email ORDER BY u.nom, u.prenom, u.id) AS emails, json_agg( json_build_object( 'id', u.id::text, 'email', u.email, 'telephone', u.telephone, 'code_postal', u.code_postal, 'ville', u.ville ) ORDER BY u.nom, u.prenom, u.id ) AS parents FROM family_rep fr JOIN parents p ON p.id_utilisateur = fr.id JOIN utilisateurs u ON u.id = p.id_utilisateur WHERE u.role = 'parent' AND u.statut = 'en_attente' GROUP BY fr.rep ORDER BY libelle `); } catch (err) { throw err; } if (!Array.isArray(raw)) return []; return raw.map((r) => ({ libelle: r.libelle ?? '', parentIds: this.normalizeParentIds(r.parentIds), numero_dossier: r.numero_dossier ?? null, date_soumission: this.toIsoDateTimeOrNull(r.date_soumission), nombre_enfants: this.normalizeNombreEnfants(r.nombre_enfants), emails: this.normalizeEmails(r.emails), parents: this.normalizeParents(r.parents), })); } private toIsoDateTimeOrNull(value: Date | string | null | undefined): string | null { if (value == null) return null; if (value instanceof Date) return value.toISOString(); const d = new Date(value); return Number.isNaN(d.getTime()) ? null : d.toISOString(); } private normalizeNombreEnfants(v: string | number | null | undefined): number { if (v == null) return 0; const n = typeof v === 'number' ? v : parseInt(String(v), 10); return Number.isFinite(n) && n >= 0 ? n : 0; } private normalizeEmails(emails: unknown): string[] { if (Array.isArray(emails)) return emails.map(String); if (typeof emails === 'string') { const s = emails.replace(/^\{|\}$/g, '').trim(); return s ? s.split(',').map((x) => x.trim()) : []; } return []; } private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] { if (Array.isArray(parents)) { return parents.map((p: any) => ({ id: String(p?.id ?? ''), email: String(p?.email ?? ''), telephone: p?.telephone != null ? String(p.telephone) : null, code_postal: p?.code_postal != null ? String(p.code_postal) : null, ville: p?.ville != null ? String(p.ville) : null, })); } if (typeof parents === 'string') { try { const parsed = JSON.parse(parents); return this.normalizeParents(parsed); } catch { return []; } } return []; } /** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */ private childToDossierFamilleEnfantDto(child: Children): DossierFamilleEnfantDto { return { id: child.id, first_name: child.first_name, last_name: child.last_name, genre: child.gender, birth_date: child.birth_date, due_date: child.due_date, status: child.status, photo_url: child.photo_url ?? undefined, consent_photo: child.consent_photo, est_multiple: child.is_multiple, }; } private normalizeParentIds(parentIds: unknown): string[] { if (Array.isArray(parentIds)) return parentIds.map(String); if (typeof parentIds === 'string') { const s = parentIds.replace(/^\{|\}$/g, '').trim(); return s ? s.split(',').map((x) => x.trim()) : []; } return []; } /** * Dossier famille complet par numéro de dossier. Ticket #119. * Rôles : admin, gestionnaire. * @throws NotFoundException si aucun parent avec ce numéro de dossier */ async getDossierFamilleByNumero(numeroDossier: string): Promise { const num = numeroDossier?.trim(); if (!num) { throw new NotFoundException('Numéro de dossier requis.'); } const firstParent = await this.parentsRepository.findOne({ where: { numero_dossier: num }, relations: ['user'], }); if (!firstParent || !firstParent.user) { throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.'); } const familyUserIds = await this.getFamilyUserIds(firstParent.user_id); const parents = await this.parentsRepository.find({ where: { user_id: In(familyUserIds) }, relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'], }); const enfantsMap = new Map(); let texte_motivation: string | undefined; // Un dossier = une famille, un seul texte de motivation const dossierFamille = await this.dossierFamilleRepository.findOne({ where: { numero_dossier: num }, relations: ['parent', 'enfants', 'enfants.enfant'], }); if (dossierFamille?.presentation) { texte_motivation = dossierFamille.presentation; } for (const p of parents) { // Enfants via parentChildren if (p.parentChildren) { for (const pc of p.parentChildren) { if (pc.child && !enfantsMap.has(pc.child.id)) { enfantsMap.set(pc.child.id, this.childToDossierFamilleEnfantDto(pc.child)); } } } // Fallback : anciens dossiers (un texte, on prend le premier) if (texte_motivation == null && p.dossiers?.length) { texte_motivation = p.dossiers[0].presentation ?? undefined; } } // Enfants uniquement liés via dossier_famille_enfants (legacy / parcours alternatif) if (dossierFamille?.enfants?.length) { for (const dfe of dossierFamille.enfants) { const c = dfe.enfant; if (c && !enfantsMap.has(c.id)) { enfantsMap.set(c.id, this.childToDossierFamilleEnfantDto(c)); } } } const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({ user_id: p.user_id, email: p.user.email, prenom: p.user.prenom, nom: p.user.nom, telephone: p.user.telephone, adresse: p.user.adresse, ville: p.user.ville, code_postal: p.user.code_postal, statut: p.user.statut, co_parent_id: p.co_parent?.id, })); return { numero_dossier: num, parents: parentsDto, enfants: Array.from(enfantsMap.values()), texte_motivation, }; } /** * Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés). * @throws NotFoundException si parentId n'est pas un parent */ async getFamilyUserIds(parentId: string): Promise { const raw = await this.parentsRepository.query( ` WITH RECURSIVE links AS ( SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL UNION ALL SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL UNION ALL SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 FROM enfants_parents ep1 JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent UNION ALL SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 FROM enfants_parents ep1 JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent ), rec AS ( SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents UNION SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 ), family_rep AS ( SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id ), input_rep AS ( SELECT rep FROM family_rep WHERE id = $1::uuid LIMIT 1 ) SELECT fr.id::text AS id FROM family_rep fr CROSS JOIN input_rep ir WHERE fr.rep = ir.rep `, [parentId], ); if (!raw || raw.length === 0) { throw new NotFoundException('Parent introuvable ou pas encore enregistré en base.'); } return raw.map((r: { id: string }) => r.id); } }