import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, IsNull, Repository } from 'typeorm'; import { RoleType, Users } from 'src/entities/users.entity'; import { Parents } from 'src/entities/parents.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AmChildren } from 'src/entities/am_children.entity'; export type SuppressionResult = { type?: 'famille' | 'assistante_maternelle'; numero_dossier?: string; deleted_user_ids: string[]; deleted_enfant_ids: string[]; dossier_supprime?: boolean; message: string; }; const STAFF_METIER: RoleType[] = [ RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, ]; /** * Cascades de suppression métier — tickets #154 / #159. */ @Injectable() export class SuppressionService { constructor( private readonly dataSource: DataSource, @InjectRepository(Users) private readonly usersRepository: Repository, @InjectRepository(Parents) private readonly parentsRepository: Repository, @InjectRepository(AssistanteMaternelle) private readonly amRepository: Repository, @InjectRepository(Children) private readonly childrenRepository: Repository, @InjectRepository(ParentsChildren) private readonly parentsChildrenRepository: Repository, @InjectRepository(AmChildren) private readonly amChildrenRepository: Repository, ) {} assertStaffMetier(currentUser: Users): void { if (!STAFF_METIER.includes(currentUser.role)) { throw new ForbiddenException('Accès refusé'); } } async deleteDossier( numeroDossier: string, currentUser: Users, ): Promise { this.assertStaffMetier(currentUser); const num = numeroDossier?.trim(); if (!num) { throw new BadRequestException('Numéro de dossier requis.'); } const parentHit = await this.parentsRepository.findOne({ where: { numero_dossier: num }, }); if (parentHit) { return this.deleteFamilleByNumero(num); } const amHit = await this.amRepository.findOne({ where: { numero_dossier: num }, relations: ['user'], }); if (amHit?.user) { return this.deleteAmUser(amHit.user.id); } throw new NotFoundException('Aucun dossier trouvé pour ce numéro.'); } async deleteUser( id: string, currentUser: Users, ): Promise { const target = await this.usersRepository.findOne({ where: { id } }); if (!target) { throw new NotFoundException('Utilisateur introuvable'); } if (target.id === currentUser.id) { throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.'); } if (target.role === RoleType.SUPER_ADMIN) { throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.'); } if (target.role === RoleType.PARENT) { this.assertStaffMetier(currentUser); return this.deleteParentUser(target.id); } if (target.role === RoleType.ASSISTANTE_MATERNELLE) { this.assertStaffMetier(currentUser); return this.deleteAmUser(target.id); } if (target.role === RoleType.GESTIONNAIRE) { if ( currentUser.role !== RoleType.ADMINISTRATEUR && currentUser.role !== RoleType.SUPER_ADMIN ) { throw new ForbiddenException( 'Seul un administrateur peut supprimer un gestionnaire.', ); } await this.usersRepository.delete(target.id); return { deleted_user_ids: [target.id], deleted_enfant_ids: [], message: 'Gestionnaire supprimé.', }; } if (target.role === RoleType.ADMINISTRATEUR) { await this.assertCanDeleteAdministrateur(target, currentUser); await this.usersRepository.delete(target.id); return { deleted_user_ids: [target.id], deleted_enfant_ids: [], message: 'Administrateur supprimé.', }; } throw new BadRequestException('Type d’utilisateur non supprimable via cet endpoint.'); } async deleteEnfant( enfantId: string, deleteDossier: boolean, currentUser: Users, ): Promise { this.assertStaffMetier(currentUser); const child = await this.childrenRepository.findOne({ where: { id: enfantId }, relations: ['parentLinks', 'parentLinks.parent'], }); if (!child) { throw new NotFoundException('Enfant introuvable'); } const parentIds = (child.parentLinks ?? []) .map((l) => l.parentId ?? l.parent?.user_id) .filter(Boolean) as string[]; let numero: string | undefined; if (parentIds.length > 0) { const parents = await this.parentsRepository.find({ where: { user_id: In(parentIds) }, }); numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n); } if (!numero) { await this.closePlacementsForEnfants([enfantId]); await this.childrenRepository.delete(enfantId); return { deleted_user_ids: [], deleted_enfant_ids: [enfantId], dossier_supprime: false, message: 'Enfant supprimé.', }; } const siblingIds = await this.listEnfantIdsForNumero(numero); const isLast = siblingIds.length <= 1; if (isLast && deleteDossier) { const result = await this.deleteFamilleByNumero(numero); return { ...result, dossier_supprime: true, message: 'Dernier enfant et dossier famille supprimés.', }; } await this.closePlacementsForEnfants([enfantId]); await this.childrenRepository.delete(enfantId); return { deleted_user_ids: [], deleted_enfant_ids: [enfantId], dossier_supprime: false, numero_dossier: numero, type: 'famille', message: isLast ? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.' : 'Enfant supprimé du dossier famille.', }; } /** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */ async countEnfantsForNumero(numeroDossier: string): Promise { const ids = await this.listEnfantIdsForNumero(numeroDossier); return ids.length; } private async assertCanDeleteAdministrateur( target: Users, currentUser: Users, ): Promise { if ( currentUser.role !== RoleType.ADMINISTRATEUR && currentUser.role !== RoleType.SUPER_ADMIN ) { throw new ForbiddenException( 'Seul un administrateur peut supprimer un administrateur.', ); } const adminCount = await this.usersRepository.count({ where: { role: RoleType.ADMINISTRATEUR }, }); if (adminCount <= 1) { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException( 'Seul le super administrateur peut supprimer le dernier administrateur.', ); } } } private async deleteParentUser(userId: string): Promise { const parent = await this.parentsRepository.findOne({ where: { user_id: userId }, relations: ['co_parent'], }); if (!parent) { // Compte parent sans fiche — hard delete user await this.usersRepository.delete(userId); return { deleted_user_ids: [userId], deleted_enfant_ids: [], message: 'Parent supprimé.', }; } const numero = parent.numero_dossier?.trim(); const foyerIds = numero ? await this.listParentUserIdsForNumero(numero) : [userId]; const isLast = foyerIds.filter((id) => id !== userId).length === 0; if (!isLast) { // Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user await this.dataSource.transaction(async (manager) => { await manager.delete(ParentsChildren, { parentId: userId }); await manager.query( `UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`, [userId], ); await manager.delete(Users, { id: userId }); }); return { deleted_user_ids: [userId], deleted_enfant_ids: [], numero_dossier: numero, type: 'famille', message: 'Parent retiré du dossier (co-parent).', }; } // Dernier parent : + enfants const enfantIds = numero ? await this.listEnfantIdsForNumero(numero) : await this.listEnfantIdsForParent(userId); await this.closePlacementsForEnfants(enfantIds); await this.dataSource.transaction(async (manager) => { if (enfantIds.length) { await manager.delete(Children, { id: In(enfantIds) }); } await manager.query( `UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`, [userId], ); await manager.delete(Users, { id: userId }); }); return { deleted_user_ids: [userId], deleted_enfant_ids: enfantIds, numero_dossier: numero, type: 'famille', message: 'Dernier parent et enfants rattachés supprimés.', }; } private async deleteFamilleByNumero(numero: string): Promise { const parentIds = await this.listParentUserIdsForNumero(numero); if (parentIds.length === 0) { throw new NotFoundException('Aucun parent pour ce dossier.'); } const enfantIds = await this.listEnfantIdsForNumero(numero); await this.closePlacementsForEnfants(enfantIds); await this.dataSource.transaction(async (manager) => { if (enfantIds.length) { await manager.delete(Children, { id: In(enfantIds) }); } await manager.query( `UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`, [parentIds], ); await manager.delete(Users, { id: In(parentIds) }); }); return { type: 'famille', numero_dossier: numero, deleted_user_ids: parentIds, deleted_enfant_ids: enfantIds, message: 'Dossier famille supprimé.', }; } private async deleteAmUser(userId: string): Promise { const am = await this.amRepository.findOne({ where: { user_id: userId } }); const numero = am?.numero_dossier?.trim(); const active = await this.amChildrenRepository.find({ where: { amId: userId, date_fin: IsNull() }, relations: ['child'], }); const now = new Date(); for (const link of active) { link.date_fin = now; await this.amChildrenRepository.save(link); if (link.child) { await this.applySansGarde(link.child); } } await this.usersRepository.delete(userId); return { type: 'assistante_maternelle', numero_dossier: numero, deleted_user_ids: [userId], deleted_enfant_ids: [], message: 'Dossier assistante maternelle supprimé.', }; } private async applySansGarde(child: Children): Promise { if ( child.status === StatutEnfantType.A_NAITRE || child.status === StatutEnfantType.SCOLARISE ) { return; } const remaining = await this.amChildrenRepository.count({ where: { enfantId: child.id, date_fin: IsNull() }, }); if (remaining === 0) { child.status = StatutEnfantType.SANS_GARDE; await this.childrenRepository.save(child); } } private async closePlacementsForEnfants(enfantIds: string[]): Promise { if (!enfantIds.length) return; const links = await this.amChildrenRepository.find({ where: { enfantId: In(enfantIds), date_fin: IsNull() }, relations: ['child'], }); const now = new Date(); for (const link of links) { link.date_fin = now; await this.amChildrenRepository.save(link); if (link.child) { await this.applySansGarde(link.child); } } } private async listParentUserIdsForNumero(numero: string): Promise { const rows: Array<{ id: string }> = await this.parentsRepository.query( ` SELECT DISTINCT x.id::text AS id FROM ( SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1 UNION SELECT id_co_parent AS id FROM parents WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL UNION SELECT p2.id_utilisateur AS id FROM parents p1 JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent WHERE TRIM(p1.numero_dossier) = $1 ) x WHERE x.id IS NOT NULL `, [numero], ); return rows.map((r) => r.id); } private async listEnfantIdsForNumero(numero: string): Promise { const parentIds = await this.listParentUserIdsForNumero(numero); if (!parentIds.length) return []; return this.listEnfantIdsForParents(parentIds); } private async listEnfantIdsForParent(parentId: string): Promise { return this.listEnfantIdsForParents([parentId]); } private async listEnfantIdsForParents(parentIds: string[]): Promise { const links = await this.parentsChildrenRepository.find({ where: { parentId: In(parentIds) }, }); return [...new Set(links.map((l) => l.enfantId))]; } }